From 240cb926700a41e7474d8939eb22d75e248bb8f6 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Mon, 2 Feb 2026 20:30:26 +0000 Subject: [PATCH 1/5] 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 --- frontend/package.json | 1 + frontend/src/app/app-routing.module.ts | 16 ++ frontend/src/app/app.component.ts | 4 +- .../dashboard-delete-dialog.component.css | 28 +++ .../dashboard-delete-dialog.component.html | 23 ++ .../dashboard-delete-dialog.component.spec.ts | 45 ++++ .../dashboard-delete-dialog.component.ts | 41 +++ .../dashboard-edit-dialog.component.css | 16 ++ .../dashboard-edit-dialog.component.html | 41 +++ .../dashboard-edit-dialog.component.spec.ts | 40 +++ .../dashboard-edit-dialog.component.ts | 83 ++++++ .../dashboard-view.component.css | 204 +++++++++++++++ .../dashboard-view.component.html | 95 +++++++ .../dashboard-view.component.spec.ts | 56 +++++ .../dashboard-view.component.ts | 238 ++++++++++++++++++ .../dashboards-list.component.css | 235 +++++++++++++++++ .../dashboards-list.component.html | 126 ++++++++++ .../dashboards-list.component.spec.ts | 58 +++++ .../dashboards-list.component.ts | 128 ++++++++++ .../widget-delete-dialog.component.css | 28 +++ .../widget-delete-dialog.component.html | 23 ++ .../widget-delete-dialog.component.spec.ts | 46 ++++ .../widget-delete-dialog.component.ts | 42 ++++ .../widget-edit-dialog.component.css | 21 ++ .../widget-edit-dialog.component.html | 65 +++++ .../widget-edit-dialog.component.spec.ts | 56 +++++ .../widget-edit-dialog.component.ts | 118 +++++++++ .../chart-widget/chart-widget.component.css | 58 +++++ .../chart-widget/chart-widget.component.html | 26 ++ .../chart-widget.component.spec.ts | 50 ++++ .../chart-widget/chart-widget.component.ts | 140 +++++++++++ .../counter-widget.component.css | 69 +++++ .../counter-widget.component.html | 25 ++ .../counter-widget.component.spec.ts | 49 ++++ .../counter-widget.component.ts | 88 +++++++ .../table-widget/table-widget.component.css | 75 ++++++ .../table-widget/table-widget.component.html | 31 +++ .../table-widget.component.spec.ts | 49 ++++ .../table-widget/table-widget.component.ts | 58 +++++ .../text-widget/text-widget.component.css | 94 +++++++ .../text-widget/text-widget.component.html | 10 + .../text-widget/text-widget.component.spec.ts | 36 +++ .../text-widget/text-widget.component.ts | 18 ++ frontend/src/app/models/dashboard.ts | 62 +++++ .../app/services/connections.service.spec.ts | 12 +- .../src/app/services/connections.service.ts | 2 +- .../src/app/services/dashboards.service.ts | 193 ++++++++++++++ frontend/yarn.lock | 14 ++ 48 files changed, 3027 insertions(+), 9 deletions(-) create mode 100644 frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.css create mode 100644 frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.html create mode 100644 frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.spec.ts create mode 100644 frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.ts create mode 100644 frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.css create mode 100644 frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.html create mode 100644 frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.spec.ts create mode 100644 frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.ts create mode 100644 frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.css create mode 100644 frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.html create mode 100644 frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.spec.ts create mode 100644 frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.ts create mode 100644 frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.css create mode 100644 frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.html create mode 100644 frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.spec.ts create mode 100644 frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.ts create mode 100644 frontend/src/app/components/dashboards/widget-delete-dialog/widget-delete-dialog.component.css create mode 100644 frontend/src/app/components/dashboards/widget-delete-dialog/widget-delete-dialog.component.html create mode 100644 frontend/src/app/components/dashboards/widget-delete-dialog/widget-delete-dialog.component.spec.ts create mode 100644 frontend/src/app/components/dashboards/widget-delete-dialog/widget-delete-dialog.component.ts create mode 100644 frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.css create mode 100644 frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.html create mode 100644 frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.spec.ts create mode 100644 frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.ts create mode 100644 frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.css create mode 100644 frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.html create mode 100644 frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.spec.ts create mode 100644 frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.ts create mode 100644 frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.css create mode 100644 frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.html create mode 100644 frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.spec.ts create mode 100644 frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.ts create mode 100644 frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.css create mode 100644 frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.html create mode 100644 frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.spec.ts create mode 100644 frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.ts create mode 100644 frontend/src/app/components/dashboards/widget-renderers/text-widget/text-widget.component.css create mode 100644 frontend/src/app/components/dashboards/widget-renderers/text-widget/text-widget.component.html create mode 100644 frontend/src/app/components/dashboards/widget-renderers/text-widget/text-widget.component.spec.ts create mode 100644 frontend/src/app/components/dashboards/widget-renderers/text-widget/text-widget.component.ts create mode 100644 frontend/src/app/models/dashboard.ts create mode 100644 frontend/src/app/services/dashboards.service.ts diff --git a/frontend/package.json b/frontend/package.json index de875bdb3..4c489b93f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,6 +36,7 @@ "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-en": "^3.0.2", "amplitude-js": "^8.21.9", + "angular-gridster2": "^21.0.1", "angular-password-strength-meter": "npm:@eresearchqut/angular-password-strength-meter@^13.0.7", "angulartics2": "^14.1.0", "chart.js": "^4.5.1", diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 01a0f249d..046ab0c2b 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -205,6 +205,22 @@ const routes: Routes = [ canActivate: [AuthGuard], title: 'Edit Query | Rocketadmin', }, + { + path: 'dashboards/:connection-id', + loadComponent: () => + import('./components/dashboards/dashboards-list/dashboards-list.component').then( + (m) => m.DashboardsListComponent, + ), + canActivate: [AuthGuard], + title: 'Dashboards | Rocketadmin', + }, + { + path: 'dashboards/:connection-id/:dashboard-id', + loadComponent: () => + import('./components/dashboards/dashboard-view/dashboard-view.component').then((m) => m.DashboardViewComponent), + canActivate: [AuthGuard], + title: 'Dashboard | Rocketadmin', + }, { path: '**', loadComponent: () => diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index a06cfc14c..be3676613 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -194,8 +194,8 @@ export class AppComponent { permissions: { caption: 'Permissions', }, - charts: { - caption: 'Charts', + dashboards: { + caption: 'Dashboards', }, 'connection-settings': { caption: 'Connection settings', diff --git a/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.css b/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.css new file mode 100644 index 000000000..b157b896c --- /dev/null +++ b/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.css @@ -0,0 +1,28 @@ +.warning-container { + display: flex; + gap: 16px; + align-items: flex-start; +} + +.warning-icon { + color: #f57c00; + font-size: 48px; + width: 48px; + height: 48px; + flex-shrink: 0; +} + +.warning-content p { + margin: 0 0 8px 0; +} + +.warning-details { + color: rgba(0, 0, 0, 0.6); + font-size: 14px; +} + +@media (prefers-color-scheme: dark) { + .warning-details { + color: rgba(255, 255, 255, 0.6); + } +} diff --git a/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.html b/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.html new file mode 100644 index 000000000..b47bed7f9 --- /dev/null +++ b/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.html @@ -0,0 +1,23 @@ +

Delete Dashboard

+ + +
+ warning +
+

Are you sure you want to delete the dashboard {{data.dashboard.name}}?

+

+ This action cannot be undone. The dashboard and all its widgets will be permanently removed. +

+
+
+
+ + + + + diff --git a/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.spec.ts b/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.spec.ts new file mode 100644 index 000000000..c0ddfc097 --- /dev/null +++ b/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.spec.ts @@ -0,0 +1,45 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { Angulartics2Module } from 'angulartics2'; +import { DashboardsService } from 'src/app/services/dashboards.service'; +import { DashboardDeleteDialogComponent } from './dashboard-delete-dialog.component'; + +describe('DashboardDeleteDialogComponent', () => { + let component: DashboardDeleteDialogComponent; + let fixture: ComponentFixture; + let mockDashboardsService: Partial; + + beforeEach(async () => { + mockDashboardsService = { + deleteDashboard: vi.fn(), + } as Partial; + + await TestBed.configureTestingModule({ + imports: [DashboardDeleteDialogComponent, BrowserAnimationsModule, Angulartics2Module.forRoot()], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { + provide: MAT_DIALOG_DATA, + useValue: { + connectionId: 'test-conn', + dashboard: { id: 'test-id', name: 'Test Dashboard' }, + }, + }, + { provide: MatDialogRef, useValue: { close: vi.fn() } }, + { provide: DashboardsService, useValue: mockDashboardsService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DashboardDeleteDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.ts b/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.ts new file mode 100644 index 000000000..9b67f4553 --- /dev/null +++ b/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.ts @@ -0,0 +1,41 @@ +import { CommonModule } from '@angular/common'; +import { Component, Inject, signal } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { Angulartics2 } from 'angulartics2'; +import { Dashboard } from 'src/app/models/dashboard'; +import { DashboardsService } from 'src/app/services/dashboards.service'; + +@Component({ + selector: 'app-dashboard-delete-dialog', + templateUrl: './dashboard-delete-dialog.component.html', + styleUrls: ['./dashboard-delete-dialog.component.css'], + imports: [CommonModule, MatDialogModule, MatButtonModule, MatIconModule], +}) +export class DashboardDeleteDialogComponent { + protected submitting = signal(false); + + constructor( + @Inject(MAT_DIALOG_DATA) public data: { dashboard: Dashboard; connectionId: string }, + private dialogRef: MatDialogRef, + private _dashboards: DashboardsService, + private angulartics2: Angulartics2, + ) {} + + onDelete(): void { + this.submitting.set(true); + this._dashboards.deleteDashboard(this.data.connectionId, this.data.dashboard.id).subscribe({ + next: () => { + this.angulartics2.eventTrack.next({ + action: 'Dashboards: dashboard deleted successfully', + }); + this.submitting.set(false); + this.dialogRef.close(true); + }, + error: () => { + this.submitting.set(false); + }, + }); + } +} diff --git a/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.css b/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.css new file mode 100644 index 000000000..7d04bba7b --- /dev/null +++ b/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.css @@ -0,0 +1,16 @@ +.dashboard-form { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 400px; +} + +@media (width <= 600px) { + .dashboard-form { + min-width: auto; + } +} + +.full-width { + width: 100%; +} diff --git a/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.html b/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.html new file mode 100644 index 000000000..dee780ba6 --- /dev/null +++ b/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.html @@ -0,0 +1,41 @@ +

{{ isEdit ? 'Edit Dashboard' : 'Create Dashboard' }}

+ + +
+ + Name + + @if (form.get('name')?.hasError('required')) { + Name is required + } + @if (form.get('name')?.hasError('maxlength')) { + Name must be less than 255 characters + } + + + + Description + + @if (form.get('description')?.hasError('maxlength')) { + Description must be less than 1000 characters + } + +
+
+ + + + + diff --git a/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.spec.ts b/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.spec.ts new file mode 100644 index 000000000..d4354b859 --- /dev/null +++ b/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.spec.ts @@ -0,0 +1,40 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { Angulartics2Module } from 'angulartics2'; +import { DashboardsService } from 'src/app/services/dashboards.service'; +import { DashboardEditDialogComponent } from './dashboard-edit-dialog.component'; + +describe('DashboardEditDialogComponent', () => { + let component: DashboardEditDialogComponent; + let fixture: ComponentFixture; + let mockDashboardsService: Partial; + + beforeEach(async () => { + mockDashboardsService = { + createDashboard: vi.fn(), + updateDashboard: vi.fn(), + } as Partial; + + await TestBed.configureTestingModule({ + imports: [DashboardEditDialogComponent, BrowserAnimationsModule, Angulartics2Module.forRoot()], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: MAT_DIALOG_DATA, useValue: { connectionId: 'test-conn', dashboard: null } }, + { provide: MatDialogRef, useValue: { close: vi.fn() } }, + { provide: DashboardsService, useValue: mockDashboardsService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DashboardEditDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.ts b/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.ts new file mode 100644 index 000000000..8abd1492c --- /dev/null +++ b/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.ts @@ -0,0 +1,83 @@ +import { CommonModule } from '@angular/common'; +import { Component, Inject, OnInit, signal } from '@angular/core'; +import { FormBuilder, 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 { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { Angulartics2 } from 'angulartics2'; +import { Dashboard } from 'src/app/models/dashboard'; +import { DashboardsService } from 'src/app/services/dashboards.service'; + +@Component({ + selector: 'app-dashboard-edit-dialog', + templateUrl: './dashboard-edit-dialog.component.html', + styleUrls: ['./dashboard-edit-dialog.component.css'], + imports: [ + CommonModule, + ReactiveFormsModule, + MatDialogModule, + MatButtonModule, + MatIconModule, + MatFormFieldModule, + MatInputModule, + ], +}) +export class DashboardEditDialogComponent implements OnInit { + protected submitting = signal(false); + protected form!: FormGroup; + protected isEdit: boolean; + + constructor( + @Inject(MAT_DIALOG_DATA) public data: { connectionId: string; dashboard: Dashboard | null }, + private dialogRef: MatDialogRef, + private _dashboards: DashboardsService, + private fb: FormBuilder, + private angulartics2: Angulartics2, + ) { + this.isEdit = !!data.dashboard; + } + + ngOnInit(): void { + this.form = this.fb.group({ + name: [this.data.dashboard?.name || '', [Validators.required, Validators.maxLength(255)]], + description: [this.data.dashboard?.description || '', [Validators.maxLength(1000)]], + }); + } + + onSubmit(): void { + if (this.form.invalid) return; + + this.submitting.set(true); + const payload = this.form.value; + + if (this.isEdit) { + this._dashboards.updateDashboard(this.data.connectionId, this.data.dashboard!.id, payload).subscribe({ + next: () => { + this.angulartics2.eventTrack.next({ + action: 'Dashboards: dashboard updated successfully', + }); + this.submitting.set(false); + this.dialogRef.close(true); + }, + error: () => { + this.submitting.set(false); + }, + }); + } else { + this._dashboards.createDashboard(this.data.connectionId, payload).subscribe({ + next: () => { + this.angulartics2.eventTrack.next({ + action: 'Dashboards: dashboard created successfully', + }); + this.submitting.set(false); + this.dialogRef.close(true); + }, + error: () => { + this.submitting.set(false); + }, + }); + } + } +} diff --git a/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.css b/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.css new file mode 100644 index 000000000..0f5bb952f --- /dev/null +++ b/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.css @@ -0,0 +1,204 @@ +.dashboard-view-page { + padding: 24px; + height: calc(100vh - 64px); + display: flex; + flex-direction: column; +} + +.dashboard-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; + flex-wrap: wrap; + gap: 16px; +} + +.header-left { + display: flex; + align-items: center; + gap: 8px; +} + +.header-info { + display: flex; + flex-direction: column; +} + +.header-info h1 { + margin: 0; + line-height: 1.2; +} + +.dashboard-description { + margin: 4px 0 0 0; + color: rgba(0, 0, 0, 0.64); + font-size: 14px; +} + +@media (prefers-color-scheme: dark) { + .dashboard-description { + color: rgba(255, 255, 255, 0.7); + } +} + +.header-actions { + display: flex; + align-items: center; + gap: 16px; +} + +.loading-container { + display: flex; + justify-content: center; + align-items: center; + flex: 1; +} + +.no-widgets { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + text-align: center; + background-color: rgba(0, 0, 0, 0.02); + border-radius: 8px; + padding: 64px 24px; +} + +@media (prefers-color-scheme: dark) { + .no-widgets { + background-color: rgba(255, 255, 255, 0.05); + } +} + +.no-widgets-icon { + font-size: 64px; + width: 64px; + height: 64px; + color: rgba(0, 0, 0, 0.26); + margin-bottom: 16px; +} + +@media (prefers-color-scheme: dark) { + .no-widgets-icon { + color: rgba(255, 255, 255, 0.3); + } +} + +.no-widgets h3 { + margin: 0 0 8px 0; + color: rgba(0, 0, 0, 0.87); +} + +@media (prefers-color-scheme: dark) { + .no-widgets h3 { + color: rgba(255, 255, 255, 0.87); + } +} + +.no-widgets p { + margin: 0 0 16px 0; + color: rgba(0, 0, 0, 0.54); +} + +@media (prefers-color-scheme: dark) { + .no-widgets p { + color: rgba(255, 255, 255, 0.54); + } +} + +.dashboard-grid { + flex: 1; + background-color: rgba(0, 0, 0, 0.02); + border-radius: 8px; +} + +@media (prefers-color-scheme: dark) { + .dashboard-grid { + background-color: rgba(255, 255, 255, 0.02); + } +} + +.widget-item { + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + overflow: hidden; +} + +@media (prefers-color-scheme: dark) { + .widget-item { + background: #1e1e1e; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + } +} + +.widget-item.edit-mode { + cursor: move; + border: 2px dashed var(--mdc-filled-button-container-color, #1976d2); +} + +.widget-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 8px 8px 16px; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + flex-shrink: 0; +} + +@media (prefers-color-scheme: dark) { + .widget-header { + border-bottom-color: rgba(255, 255, 255, 0.12); + } +} + +.widget-title { + font-weight: 500; + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.widget-menu-button { + flex-shrink: 0; +} + +.widget-content { + flex: 1; + padding: 16px; + overflow: auto; + display: flex; + flex-direction: column; +} + +.delete-action { + color: #c62828; +} + +@media (prefers-color-scheme: dark) { + .delete-action { + color: #ef5350; + } +} + +@media (width <= 600px) { + .dashboard-view-page { + padding: 16px; + } + + .dashboard-header { + flex-direction: column; + align-items: flex-start; + } + + .header-actions { + width: 100%; + justify-content: space-between; + } +} diff --git a/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.html b/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.html new file mode 100644 index 000000000..ac6b742e9 --- /dev/null +++ b/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.html @@ -0,0 +1,95 @@ + + +
+
+
+ +
+

{{ dashboard()?.name || 'Loading...' }}

+

+ {{ dashboard()?.description }} +

+
+
+
+ + Edit Mode + + +
+
+ +
+ +
+ +
+ bar_chart +

No charts yet

+

Add charts to visualize your data from saved queries.

+ +
+ + + @for (item of gridsterItems(); track item.widget.id) { + +
+ {{ item.widget.name }} + + + + + +
+
+ @switch (item.widget.widget_type) { + @case ('chart') { + + + } + @case ('table') { + + + } + @case ('counter') { + + + } + @case ('text') { + + + } + } +
+
+ } +
+
diff --git a/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.spec.ts b/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.spec.ts new file mode 100644 index 000000000..fba785505 --- /dev/null +++ b/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.spec.ts @@ -0,0 +1,56 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { signal, WritableSignal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Angulartics2Module } from 'angulartics2'; +import { of } from 'rxjs'; +import { Dashboard } from 'src/app/models/dashboard'; +import { ConnectionsService } from 'src/app/services/connections.service'; +import { DashboardsService } from 'src/app/services/dashboards.service'; +import { DashboardViewComponent } from './dashboard-view.component'; + +describe('DashboardViewComponent', () => { + let component: DashboardViewComponent; + let fixture: ComponentFixture; + let mockDashboardsService: Partial; + let mockConnectionsService: Partial; + let dashboardSignal: WritableSignal; + let dashboardLoadingSignal: WritableSignal; + + beforeEach(async () => { + dashboardSignal = signal(null); + dashboardLoadingSignal = signal(false); + + mockDashboardsService = { + dashboard: dashboardSignal.asReadonly(), + dashboardLoading: dashboardLoadingSignal.asReadonly(), + setActiveConnection: vi.fn(), + setActiveDashboard: vi.fn(), + refreshDashboard: vi.fn(), + }; + + mockConnectionsService = { + getCurrentConnectionTitle: vi.fn().mockReturnValue(of('Test Connection')), + }; + + await TestBed.configureTestingModule({ + imports: [DashboardViewComponent, BrowserAnimationsModule, RouterTestingModule, Angulartics2Module.forRoot()], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: DashboardsService, useValue: mockDashboardsService }, + { provide: ConnectionsService, useValue: mockConnectionsService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DashboardViewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.ts b/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.ts new file mode 100644 index 000000000..95f90302a --- /dev/null +++ b/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.ts @@ -0,0 +1,238 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, effect, inject, OnInit, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { Title } from '@angular/platform-browser'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { + CompactType, + DisplayGrid, + Gridster, + GridsterConfig, + GridsterItem, + GridsterItemConfig, + GridType, +} from 'angular-gridster2'; +import { Angulartics2 } from 'angulartics2'; +import { DashboardWidget } from 'src/app/models/dashboard'; +import { ConnectionsService } from 'src/app/services/connections.service'; +import { DashboardsService } from 'src/app/services/dashboards.service'; +import { AlertComponent } from '../../ui-components/alert/alert.component'; +import { WidgetDeleteDialogComponent } from '../widget-delete-dialog/widget-delete-dialog.component'; +import { WidgetEditDialogComponent } from '../widget-edit-dialog/widget-edit-dialog.component'; +import { ChartWidgetComponent } from '../widget-renderers/chart-widget/chart-widget.component'; +import { CounterWidgetComponent } from '../widget-renderers/counter-widget/counter-widget.component'; +import { TableWidgetComponent } from '../widget-renderers/table-widget/table-widget.component'; +import { TextWidgetComponent } from '../widget-renderers/text-widget/text-widget.component'; + +interface GridsterWidgetItem extends GridsterItemConfig { + widget: DashboardWidget; +} + +@Component({ + selector: 'app-dashboard-view', + templateUrl: './dashboard-view.component.html', + styleUrls: ['./dashboard-view.component.css'], + imports: [ + CommonModule, + RouterModule, + MatButtonModule, + MatIconModule, + MatMenuModule, + MatTooltipModule, + MatSlideToggleModule, + MatProgressSpinnerModule, + Gridster, + GridsterItem, + AlertComponent, + ChartWidgetComponent, + TableWidgetComponent, + CounterWidgetComponent, + TextWidgetComponent, + ], +}) +export class DashboardViewComponent implements OnInit { + protected connectionId = signal(''); + protected dashboardId = signal(''); + protected editMode = signal(false); + + private _dashboards = inject(DashboardsService); + private _connections = inject(ConnectionsService); + private route = inject(ActivatedRoute); + private router = inject(Router); + private dialog = inject(MatDialog); + private angulartics2 = inject(Angulartics2); + private title = inject(Title); + + // Use service signals + protected dashboard = computed(() => this._dashboards.dashboard()); + protected loading = computed(() => this._dashboards.dashboardLoading()); + + // Convert widgets to gridster items + protected gridsterItems = computed(() => { + const dashboard = this.dashboard(); + if (!dashboard?.widgets) return []; + return dashboard.widgets.map((widget) => ({ + x: widget.position_x, + y: widget.position_y, + cols: widget.width, + rows: widget.height, + widget: widget, + })); + }); + + protected gridsterOptions: GridsterConfig = { + gridType: GridType.Fit, + compactType: CompactType.None, + displayGrid: DisplayGrid.OnDragAndResize, + pushItems: true, + draggable: { + enabled: false, + }, + resizable: { + enabled: false, + }, + minCols: 12, + maxCols: 12, + minRows: 8, + maxRows: 100, + defaultItemCols: 4, + defaultItemRows: 4, + minItemCols: 2, + minItemRows: 2, + maxItemCols: 12, + maxItemRows: 12, + itemChangeCallback: (item: GridsterItemConfig, itemComponent: GridsterItem) => + this._onItemChange(item as GridsterWidgetItem), + }; + + private connectionTitle = toSignal(this._connections.getCurrentConnectionTitle(), { initialValue: '' }); + + constructor() { + // Connection title effect + effect(() => { + const dashboard = this.dashboard(); + const connectionTitle = this.connectionTitle(); + if (dashboard) { + this.title.setTitle(`${dashboard.name} | ${connectionTitle || 'Rocketadmin'}`); + } + }); + + // Edit mode effect + effect(() => { + const editMode = this.editMode(); + if (this.gridsterOptions.draggable) { + this.gridsterOptions.draggable.enabled = editMode; + } + if (this.gridsterOptions.resizable) { + this.gridsterOptions.resizable.enabled = editMode; + } + this.gridsterOptions.displayGrid = editMode ? DisplayGrid.Always : DisplayGrid.OnDragAndResize; + if (this.gridsterOptions.api?.optionsChanged) { + this.gridsterOptions.api.optionsChanged(); + } + }); + } + + ngOnInit(): void { + const connId = this.route.snapshot.paramMap.get('connection-id') || ''; + const dashId = this.route.snapshot.paramMap.get('dashboard-id') || ''; + this.connectionId.set(connId); + this.dashboardId.set(dashId); + this._dashboards.setActiveConnection(connId); + this._dashboards.setActiveDashboard(dashId); + } + + toggleEditMode(): void { + this.editMode.update((v) => !v); + this.angulartics2.eventTrack.next({ + action: `Dashboards: edit mode ${this.editMode() ? 'enabled' : 'disabled'}`, + }); + } + + openAddWidgetDialog(): void { + const dialogRef = this.dialog.open(WidgetEditDialogComponent, { + width: '600px', + data: { + connectionId: this.connectionId(), + dashboardId: this.dashboardId(), + widget: null, + }, + }); + dialogRef.afterClosed().subscribe((result) => { + if (result) { + this._dashboards.refreshDashboard(); + } + }); + this.angulartics2.eventTrack.next({ + action: 'Dashboards: add widget dialog opened', + }); + } + + openEditWidgetDialog(widget: DashboardWidget): void { + const dialogRef = this.dialog.open(WidgetEditDialogComponent, { + width: '600px', + data: { + connectionId: this.connectionId(), + dashboardId: this.dashboardId(), + widget: widget, + }, + }); + dialogRef.afterClosed().subscribe((result) => { + if (result) { + this._dashboards.refreshDashboard(); + } + }); + this.angulartics2.eventTrack.next({ + action: 'Dashboards: edit widget dialog opened', + }); + } + + openDeleteWidgetDialog(widget: DashboardWidget): void { + const dialogRef = this.dialog.open(WidgetDeleteDialogComponent, { + width: '400px', + data: { + connectionId: this.connectionId(), + dashboardId: this.dashboardId(), + widget: widget, + }, + }); + dialogRef.afterClosed().subscribe((result) => { + if (result) { + this._dashboards.refreshDashboard(); + } + }); + this.angulartics2.eventTrack.next({ + action: 'Dashboards: delete widget dialog opened', + }); + } + + navigateBack(): void { + this.router.navigate(['/dashboards', this.connectionId()]); + } + + private _onItemChange(item: GridsterWidgetItem): void { + const widget = item.widget; + if ( + widget.position_x !== item.x || + widget.position_y !== item.y || + widget.width !== item.cols || + widget.height !== item.rows + ) { + this._dashboards + .updateWidgetPosition(this.connectionId(), this.dashboardId(), widget.id, { + position_x: item.x, + position_y: item.y, + width: item.cols, + height: item.rows, + }) + .subscribe(); + } + } +} diff --git a/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.css b/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.css new file mode 100644 index 000000000..e4878a054 --- /dev/null +++ b/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.css @@ -0,0 +1,235 @@ +.dashboards-page { + margin: 3em auto; + padding: 0 clamp(200px, 20vw, 300px); +} + +@media (width <= 600px) { + .dashboards-page { + padding: 0 9vw; + } +} + +.dashboards-header { + margin-bottom: 32px; +} + +.dashboards-description { + color: rgba(0, 0, 0, 0.64); + margin-top: 8px; +} + +@media (prefers-color-scheme: dark) { + .dashboards-description { + color: rgba(255, 255, 255, 0.7); + } +} + +.dashboards-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; +} + +@media (width <= 600px) { + .dashboards-toolbar { + flex-direction: column; + align-items: stretch; + } +} + +.search-field { + flex: 1; + max-width: 400px; +} + +@media (width <= 600px) { + .search-field { + max-width: 100%; + } +} + +.toolbar-actions { + display: flex; + gap: 12px; + align-items: center; +} + +@media (width <= 600px) { + .toolbar-actions { + flex-direction: column; + width: 100%; + } + + .toolbar-actions a, + .toolbar-actions button { + width: 100%; + } +} + +.dashboards-table { + width: 100%; + margin-bottom: 16px; +} + +.dashboards-cell_name { + max-width: 250px; +} + +.name-link { + text-decoration: none; + color: inherit; +} + +.name-link:hover .name-text { + text-decoration: underline; +} + +.name-text { + font-weight: 500; +} + +.description-text { + color: rgba(0, 0, 0, 0.87); +} + +@media (prefers-color-scheme: dark) { + .description-text { + color: rgba(255, 255, 255, 0.87); + } +} + +.no-description { + color: rgba(0, 0, 0, 0.38); + font-style: italic; +} + +@media (prefers-color-scheme: dark) { + .no-description { + color: rgba(255, 255, 255, 0.38); + } +} + +.dashboards-cell_actions { + text-align: right; +} + +.no-dashboards { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 64px 24px; + text-align: center; + background-color: rgba(0, 0, 0, 0.02); + border-radius: 8px; +} + +@media (prefers-color-scheme: dark) { + .no-dashboards { + background-color: rgba(255, 255, 255, 0.05); + } +} + +.no-dashboards-icon { + font-size: 64px; + width: 64px; + height: 64px; + color: rgba(0, 0, 0, 0.26); + margin-bottom: 16px; +} + +@media (prefers-color-scheme: dark) { + .no-dashboards-icon { + color: rgba(255, 255, 255, 0.3); + } +} + +.no-dashboards h3 { + margin: 0 0 8px 0; + color: rgba(0, 0, 0, 0.87); +} + +@media (prefers-color-scheme: dark) { + .no-dashboards h3 { + color: rgba(255, 255, 255, 0.87); + } +} + +.no-dashboards p { + margin: 0 0 16px 0; + color: rgba(0, 0, 0, 0.54); +} + +@media (prefers-color-scheme: dark) { + .no-dashboards p { + color: rgba(255, 255, 255, 0.54); + } +} + +.delete-action { + color: #c62828; +} + +@media (prefers-color-scheme: dark) { + .delete-action { + color: #ef5350; + } +} + +/* Responsive table styles */ +@media (width <= 600px) { + .dashboards-table { + display: grid; + grid-template-columns: auto 1fr; + max-width: 100%; + } + + .dashboards-table-heading { + display: none; + } + + .dashboards-table ::ng-deep tbody { + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / 3; + } + + .dashboards-row { + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / 3; + grid-gap: 12px 28px; + border-bottom-color: var(--mat-table-row-item-outline-color, rgba(0, 0, 0, 0.12)); + border-bottom-width: var(--mat-table-row-item-outline-width, 1px); + border-bottom-style: solid; + height: auto; + padding: 20px 0; + } + + .dashboards-cell { + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / 3; + border-bottom: none; + } + + .dashboards-cell::before { + content: attr(data-label); + display: inline-block; + font-weight: bold; + white-space: nowrap; + } + + .dashboards-cell_actions { + grid-column: 1 / span 3; + display: flex; + justify-content: flex-end; + border-bottom: none; + } + + .dashboards-cell_actions::before { + display: none; + } +} diff --git a/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.html b/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.html new file mode 100644 index 000000000..1d5f770ef --- /dev/null +++ b/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.html @@ -0,0 +1,126 @@ + + +
+
+

Dashboards

+

+ Create and manage custom dashboards with charts, tables, and metrics. +

+
+ +
+ + Search dashboards + + search + + +
+ + code + Manage Saved Queries + + +
+
+ + + +
+ dashboard +

No dashboards found

+

No dashboards match your search criteria.

+

Create your first dashboard to visualize your data.

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name + + {{dashboard.name}} + + Description + {{dashboard.description}} + No description + Last Updated + {{dashboard.updated_at | date:'medium'}} + + + + + visibility + View + + + + + +
+
diff --git a/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.spec.ts b/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.spec.ts new file mode 100644 index 000000000..4d1d52fc4 --- /dev/null +++ b/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.spec.ts @@ -0,0 +1,58 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { signal, WritableSignal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Angulartics2Module } from 'angulartics2'; +import { of } from 'rxjs'; +import { Dashboard } from 'src/app/models/dashboard'; +import { ConnectionsService } from 'src/app/services/connections.service'; +import { DashboardsService, DashboardUpdateEvent } from 'src/app/services/dashboards.service'; +import { DashboardsListComponent } from './dashboards-list.component'; + +describe('DashboardsListComponent', () => { + let component: DashboardsListComponent; + let fixture: ComponentFixture; + let mockDashboardsService: Partial; + let mockConnectionsService: Partial; + let dashboardsUpdatedSignal: WritableSignal; + let dashboardsSignal: WritableSignal; + let dashboardsLoadingSignal: WritableSignal; + + beforeEach(async () => { + dashboardsUpdatedSignal = signal(''); + dashboardsSignal = signal([]); + dashboardsLoadingSignal = signal(false); + + mockDashboardsService = { + dashboards: dashboardsSignal.asReadonly(), + dashboardsLoading: dashboardsLoadingSignal.asReadonly(), + dashboardsUpdated: dashboardsUpdatedSignal.asReadonly(), + setActiveConnection: vi.fn(), + refreshDashboards: vi.fn(), + }; + + mockConnectionsService = { + getCurrentConnectionTitle: vi.fn().mockReturnValue(of('Test Connection')), + }; + + await TestBed.configureTestingModule({ + imports: [DashboardsListComponent, BrowserAnimationsModule, RouterTestingModule, Angulartics2Module.forRoot()], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: DashboardsService, useValue: mockDashboardsService }, + { provide: ConnectionsService, useValue: mockConnectionsService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DashboardsListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.ts b/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.ts new file mode 100644 index 000000000..10ddffa00 --- /dev/null +++ b/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.ts @@ -0,0 +1,128 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, effect, inject, OnInit, signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatTableModule } from '@angular/material/table'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { Title } from '@angular/platform-browser'; +import { ActivatedRoute, RouterModule } from '@angular/router'; +import { Angulartics2 } from 'angulartics2'; +import { Dashboard } from 'src/app/models/dashboard'; +import { ConnectionsService } from 'src/app/services/connections.service'; +import { DashboardsService } from 'src/app/services/dashboards.service'; +import { PlaceholderTableDataComponent } from '../../skeletons/placeholder-table-data/placeholder-table-data.component'; +import { AlertComponent } from '../../ui-components/alert/alert.component'; +import { DashboardDeleteDialogComponent } from '../dashboard-delete-dialog/dashboard-delete-dialog.component'; +import { DashboardEditDialogComponent } from '../dashboard-edit-dialog/dashboard-edit-dialog.component'; + +@Component({ + selector: 'app-dashboards-list', + templateUrl: './dashboards-list.component.html', + styleUrls: ['./dashboards-list.component.css'], + imports: [ + CommonModule, + FormsModule, + RouterModule, + MatTableModule, + MatButtonModule, + MatIconModule, + MatMenuModule, + MatInputModule, + MatFormFieldModule, + MatTooltipModule, + MatDividerModule, + PlaceholderTableDataComponent, + AlertComponent, + ], +}) +export class DashboardsListComponent implements OnInit { + protected searchQuery = signal(''); + protected connectionId = signal(''); + public displayedColumns = ['name', 'description', 'updatedAt', 'actions']; + + private _dashboards = inject(DashboardsService); + private _connections = inject(ConnectionsService); + private route = inject(ActivatedRoute); + private dialog = inject(MatDialog); + private angulartics2 = inject(Angulartics2); + private title = inject(Title); + + // Use service signals for dashboards and loading + protected dashboards = computed(() => this._dashboards.dashboards()); + protected loading = computed(() => this._dashboards.dashboardsLoading()); + + protected filteredDashboards = computed(() => { + const dashboards = this.dashboards(); + const search = this.searchQuery(); + if (!search) return dashboards; + const query = search.toLowerCase(); + return dashboards.filter( + (d) => d.name.toLowerCase().includes(query) || (d.description && d.description.toLowerCase().includes(query)), + ); + }); + + private connectionTitle = toSignal(this._connections.getCurrentConnectionTitle(), { initialValue: '' }); + + constructor() { + // Connection title effect + effect(() => { + const title = this.connectionTitle(); + this.title.setTitle(`Dashboards | ${title || 'Rocketadmin'}`); + }); + + // Dashboards update effect + effect(() => { + const action = this._dashboards.dashboardsUpdated(); + if (action) this._dashboards.refreshDashboards(); + }); + } + + ngOnInit(): void { + const connId = this.route.snapshot.paramMap.get('connection-id') || ''; + this.connectionId.set(connId); + this._dashboards.setActiveConnection(connId); + } + + trackViewDashboardOpened(): void { + this.angulartics2.eventTrack.next({ + action: 'Dashboards: view dashboard opened', + }); + } + + openCreateDialog(): void { + this.dialog.open(DashboardEditDialogComponent, { + width: '500px', + data: { connectionId: this.connectionId(), dashboard: null }, + }); + this.angulartics2.eventTrack.next({ + action: 'Dashboards: create dashboard dialog opened', + }); + } + + openEditDialog(dashboard: Dashboard): void { + this.dialog.open(DashboardEditDialogComponent, { + width: '500px', + data: { connectionId: this.connectionId(), dashboard }, + }); + this.angulartics2.eventTrack.next({ + action: 'Dashboards: edit dashboard dialog opened', + }); + } + + openDeleteDialog(dashboard: Dashboard): void { + this.dialog.open(DashboardDeleteDialogComponent, { + width: '400px', + data: { dashboard, connectionId: this.connectionId() }, + }); + this.angulartics2.eventTrack.next({ + action: 'Dashboards: delete dashboard dialog opened', + }); + } +} diff --git a/frontend/src/app/components/dashboards/widget-delete-dialog/widget-delete-dialog.component.css b/frontend/src/app/components/dashboards/widget-delete-dialog/widget-delete-dialog.component.css new file mode 100644 index 000000000..b157b896c --- /dev/null +++ b/frontend/src/app/components/dashboards/widget-delete-dialog/widget-delete-dialog.component.css @@ -0,0 +1,28 @@ +.warning-container { + display: flex; + gap: 16px; + align-items: flex-start; +} + +.warning-icon { + color: #f57c00; + font-size: 48px; + width: 48px; + height: 48px; + flex-shrink: 0; +} + +.warning-content p { + margin: 0 0 8px 0; +} + +.warning-details { + color: rgba(0, 0, 0, 0.6); + font-size: 14px; +} + +@media (prefers-color-scheme: dark) { + .warning-details { + color: rgba(255, 255, 255, 0.6); + } +} diff --git a/frontend/src/app/components/dashboards/widget-delete-dialog/widget-delete-dialog.component.html b/frontend/src/app/components/dashboards/widget-delete-dialog/widget-delete-dialog.component.html new file mode 100644 index 000000000..0ea46e552 --- /dev/null +++ b/frontend/src/app/components/dashboards/widget-delete-dialog/widget-delete-dialog.component.html @@ -0,0 +1,23 @@ +

Delete Widget

+ + +
+ warning +
+

Are you sure you want to delete the widget {{data.widget.name}}?

+

+ This action cannot be undone. The widget will be permanently removed from the dashboard. +

+
+
+
+ + + + + diff --git a/frontend/src/app/components/dashboards/widget-delete-dialog/widget-delete-dialog.component.spec.ts b/frontend/src/app/components/dashboards/widget-delete-dialog/widget-delete-dialog.component.spec.ts new file mode 100644 index 000000000..d986e70a6 --- /dev/null +++ b/frontend/src/app/components/dashboards/widget-delete-dialog/widget-delete-dialog.component.spec.ts @@ -0,0 +1,46 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { Angulartics2Module } from 'angulartics2'; +import { DashboardsService } from 'src/app/services/dashboards.service'; +import { WidgetDeleteDialogComponent } from './widget-delete-dialog.component'; + +describe('WidgetDeleteDialogComponent', () => { + let component: WidgetDeleteDialogComponent; + let fixture: ComponentFixture; + let mockDashboardsService: Partial; + + beforeEach(async () => { + mockDashboardsService = { + deleteWidget: vi.fn(), + } as Partial; + + await TestBed.configureTestingModule({ + imports: [WidgetDeleteDialogComponent, BrowserAnimationsModule, Angulartics2Module.forRoot()], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { + provide: MAT_DIALOG_DATA, + useValue: { + connectionId: 'test-conn', + dashboardId: 'test-dash', + widget: { id: 'test-id', name: 'Test Widget' }, + }, + }, + { provide: MatDialogRef, useValue: { close: vi.fn() } }, + { provide: DashboardsService, useValue: mockDashboardsService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(WidgetDeleteDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/dashboards/widget-delete-dialog/widget-delete-dialog.component.ts b/frontend/src/app/components/dashboards/widget-delete-dialog/widget-delete-dialog.component.ts new file mode 100644 index 000000000..96fc65185 --- /dev/null +++ b/frontend/src/app/components/dashboards/widget-delete-dialog/widget-delete-dialog.component.ts @@ -0,0 +1,42 @@ +import { CommonModule } from '@angular/common'; +import { Component, Inject, signal } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { Angulartics2 } from 'angulartics2'; +import { DashboardWidget } from 'src/app/models/dashboard'; +import { DashboardsService } from 'src/app/services/dashboards.service'; + +@Component({ + selector: 'app-widget-delete-dialog', + templateUrl: './widget-delete-dialog.component.html', + styleUrls: ['./widget-delete-dialog.component.css'], + imports: [CommonModule, MatDialogModule, MatButtonModule, MatIconModule], +}) +export class WidgetDeleteDialogComponent { + protected submitting = signal(false); + + constructor( + @Inject(MAT_DIALOG_DATA) + public data: { connectionId: string; dashboardId: string; widget: DashboardWidget }, + private dialogRef: MatDialogRef, + private _dashboards: DashboardsService, + private angulartics2: Angulartics2, + ) {} + + onDelete(): void { + this.submitting.set(true); + this._dashboards.deleteWidget(this.data.connectionId, this.data.dashboardId, this.data.widget.id).subscribe({ + next: () => { + this.angulartics2.eventTrack.next({ + action: 'Dashboards: widget deleted successfully', + }); + this.submitting.set(false); + this.dialogRef.close(true); + }, + error: () => { + this.submitting.set(false); + }, + }); + } +} diff --git a/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.css b/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.css new file mode 100644 index 000000000..3e062464e --- /dev/null +++ b/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.css @@ -0,0 +1,21 @@ +.widget-form { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 500px; +} + +@media (width <= 600px) { + .widget-form { + min-width: auto; + } +} + +.full-width { + width: 100%; +} + +.type-icon { + margin-right: 8px; + vertical-align: middle; +} diff --git a/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.html b/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.html new file mode 100644 index 000000000..b3b28eb32 --- /dev/null +++ b/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.html @@ -0,0 +1,65 @@ +

{{ isEdit ? 'Edit Chart' : 'Add Chart' }}

+ + +
+ + Name + + @if (form.get('name')?.hasError('required')) { + Name is required + } + @if (form.get('name')?.hasError('maxlength')) { + Name must be less than 255 characters + } + + + + Description + + @if (form.get('description')?.hasError('maxlength')) { + Description must be less than 1000 characters + } + + + + Chart Type + + @for (type of chartTypes; track type.value) { + {{ type.label }} + } + + + + + Saved Query + + @for (query of savedQueries(); track query.id) { + {{ query.name }} + } + + @if (form.get('query_id')?.hasError('required')) { + Saved query is required + } + Select a saved query to fetch data for this chart + +
+
+ + + + + diff --git a/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.spec.ts b/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.spec.ts new file mode 100644 index 000000000..a8aefe1c9 --- /dev/null +++ b/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.spec.ts @@ -0,0 +1,56 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { signal, WritableSignal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { Angulartics2Module } from 'angulartics2'; +import { SavedQuery } from 'src/app/models/saved-query'; +import { DashboardsService } from 'src/app/services/dashboards.service'; +import { SavedQueriesService } from 'src/app/services/saved-queries.service'; +import { WidgetEditDialogComponent } from './widget-edit-dialog.component'; + +describe('WidgetEditDialogComponent', () => { + let component: WidgetEditDialogComponent; + let fixture: ComponentFixture; + let mockDashboardsService: Partial; + let mockSavedQueriesService: Partial; + let savedQueriesSignal: WritableSignal; + + beforeEach(async () => { + savedQueriesSignal = signal([]); + + mockDashboardsService = { + createWidget: vi.fn(), + updateWidget: vi.fn(), + } as Partial; + + mockSavedQueriesService = { + savedQueries: savedQueriesSignal.asReadonly(), + setActiveConnection: vi.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [WidgetEditDialogComponent, BrowserAnimationsModule, Angulartics2Module.forRoot()], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { + provide: MAT_DIALOG_DATA, + useValue: { connectionId: 'test-conn', dashboardId: 'test-dash', widget: null }, + }, + { provide: MatDialogRef, useValue: { close: vi.fn() } }, + { provide: DashboardsService, useValue: mockDashboardsService }, + { provide: SavedQueriesService, useValue: mockSavedQueriesService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(WidgetEditDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.ts b/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.ts new file mode 100644 index 000000000..d5e0ebda0 --- /dev/null +++ b/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.ts @@ -0,0 +1,118 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, Inject, OnInit, signal } from '@angular/core'; +import { FormBuilder, 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'; +import { MatSelectModule } from '@angular/material/select'; +import { Angulartics2 } from 'angulartics2'; +import { DashboardWidget } from 'src/app/models/dashboard'; +import { DashboardsService } from 'src/app/services/dashboards.service'; +import { SavedQueriesService } from 'src/app/services/saved-queries.service'; + +@Component({ + selector: 'app-widget-edit-dialog', + templateUrl: './widget-edit-dialog.component.html', + styleUrls: ['./widget-edit-dialog.component.css'], + imports: [ + CommonModule, + ReactiveFormsModule, + MatDialogModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + ], +}) +export class WidgetEditDialogComponent implements OnInit { + protected submitting = signal(false); + protected form!: FormGroup; + protected isEdit: boolean; + + protected chartTypes = [ + { value: 'bar', label: 'Bar Chart' }, + { value: 'line', label: 'Line Chart' }, + { value: 'pie', label: 'Pie Chart' }, + { value: 'doughnut', label: 'Doughnut Chart' }, + { value: 'polarArea', label: 'Polar Area Chart' }, + ]; + + protected savedQueries = computed(() => this._savedQueries.savedQueries()); + + constructor( + @Inject(MAT_DIALOG_DATA) + public data: { connectionId: string; dashboardId: string; widget: DashboardWidget | null }, + private dialogRef: MatDialogRef, + private _dashboards: DashboardsService, + private _savedQueries: SavedQueriesService, + private fb: FormBuilder, + private angulartics2: Angulartics2, + ) { + this.isEdit = !!data.widget; + } + + ngOnInit(): void { + this._savedQueries.setActiveConnection(this.data.connectionId); + + const widget = this.data.widget; + this.form = this.fb.group({ + name: [widget?.name || '', [Validators.required, Validators.maxLength(255)]], + description: [widget?.description || '', [Validators.maxLength(1000)]], + chart_type: [widget?.chart_type || 'bar', [Validators.required]], + query_id: [widget?.query_id || '', [Validators.required]], + }); + } + + onSubmit(): void { + if (this.form.invalid) return; + + this.submitting.set(true); + const formValue = this.form.value; + + const payload: Record = { + name: formValue.name, + description: formValue.description || undefined, + widget_type: 'chart', + chart_type: formValue.chart_type, + query_id: formValue.query_id, + }; + + if (!this.isEdit) { + payload['position_x'] = 0; + payload['position_y'] = 0; + payload['width'] = 4; + payload['height'] = 4; + } + + if (this.isEdit) { + this._dashboards + .updateWidget(this.data.connectionId, this.data.dashboardId, this.data.widget!.id, payload as any) + .subscribe({ + next: () => { + this.angulartics2.eventTrack.next({ + action: 'Dashboards: chart updated successfully', + }); + this.submitting.set(false); + this.dialogRef.close(true); + }, + error: () => { + this.submitting.set(false); + }, + }); + } else { + this._dashboards.createWidget(this.data.connectionId, this.data.dashboardId, payload as any).subscribe({ + next: () => { + this.angulartics2.eventTrack.next({ + action: 'Dashboards: chart created successfully', + }); + this.submitting.set(false); + this.dialogRef.close(true); + }, + error: () => { + this.submitting.set(false); + }, + }); + } + } +} diff --git a/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.css b/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.css new file mode 100644 index 000000000..459d57d34 --- /dev/null +++ b/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.css @@ -0,0 +1,58 @@ +:host { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; +} + +.loading-container, +.error-container, +.no-query-container, +.no-data-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + text-align: center; +} + +.error-message { + color: #c62828; + margin: 0; +} + +@media (prefers-color-scheme: dark) { + .error-message { + color: #ef5350; + } +} + +.no-query-container p, +.no-data-container p { + margin: 0; + color: rgba(0, 0, 0, 0.54); +} + +@media (prefers-color-scheme: dark) { + .no-query-container p, + .no-data-container p { + color: rgba(255, 255, 255, 0.54); + } +} + +.hint { + font-size: 12px; + margin-top: 4px !important; +} + +.chart-container { + flex: 1; + position: relative; + min-height: 0; +} + +.chart-container canvas { + width: 100% !important; + height: 100% !important; +} diff --git a/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.html b/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.html new file mode 100644 index 000000000..b70ac4f7c --- /dev/null +++ b/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.html @@ -0,0 +1,26 @@ +@if (loading()) { +
+ +
+} @else if (error()) { +
+

{{ error() }}

+
+} @else if (!widget.query_id) { +
+

No query linked to this chart widget.

+

Edit this widget to link a saved query.

+
+} @else if (chartData()) { +
+ + +
+} @else { +
+

No data available

+
+} diff --git a/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.spec.ts b/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.spec.ts new file mode 100644 index 000000000..1d4b20898 --- /dev/null +++ b/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.spec.ts @@ -0,0 +1,50 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { SavedQueriesService } from 'src/app/services/saved-queries.service'; +import { ChartWidgetComponent } from './chart-widget.component'; + +describe('ChartWidgetComponent', () => { + let component: ChartWidgetComponent; + let fixture: ComponentFixture; + let mockSavedQueriesService: Partial; + + beforeEach(async () => { + mockSavedQueriesService = { + executeSavedQuery: vi.fn(), + } as Partial; + + await TestBed.configureTestingModule({ + imports: [ChartWidgetComponent, BrowserAnimationsModule], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: SavedQueriesService, useValue: mockSavedQueriesService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ChartWidgetComponent); + component = fixture.componentInstance; + component.widget = { + id: 'test-id', + name: 'Test Chart', + widget_type: 'chart', + chart_type: 'bar', + description: null, + position_x: 0, + position_y: 0, + width: 4, + height: 4, + widget_options: {}, + query_id: null, + dashboard_id: 'test-dashboard', + }; + component.connectionId = 'test-conn'; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.ts b/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.ts new file mode 100644 index 000000000..a19ffbef3 --- /dev/null +++ b/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.ts @@ -0,0 +1,140 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, effect, Input, inject, signal } from '@angular/core'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { ChartConfiguration, ChartData, ChartType as ChartJsType } from 'chart.js'; +import { BaseChartDirective } from 'ng2-charts'; +import { DashboardWidget } from 'src/app/models/dashboard'; +import { SavedQueriesService } from 'src/app/services/saved-queries.service'; + +@Component({ + selector: 'app-chart-widget', + templateUrl: './chart-widget.component.html', + styleUrls: ['./chart-widget.component.css'], + imports: [CommonModule, BaseChartDirective, MatProgressSpinnerModule], +}) +export class ChartWidgetComponent { + @Input({ required: true }) widget!: DashboardWidget; + @Input({ required: true }) connectionId!: string; + + private _savedQueries = inject(SavedQueriesService); + + protected loading = signal(false); + protected error = signal(null); + protected data = signal[]>([]); + + protected chartData = computed | null>(() => { + const data = this.data(); + if (!data.length) return null; + + const labelColumn = (this.widget.widget_options?.['label_column'] as string) || this._getFirstColumn(data); + const valueColumn = (this.widget.widget_options?.['value_column'] as string) || this._getSecondColumn(data); + + if (!labelColumn || !valueColumn) return null; + + const labels = data.map((row) => String(row[labelColumn] ?? '')); + const values = data.map((row) => { + const val = row[valueColumn]; + return typeof val === 'number' ? val : parseFloat(String(val)) || 0; + }); + + const isPieType = ['pie', 'doughnut', 'polarArea'].includes(this.widget.chart_type || 'bar'); + + if (isPieType) { + return { + labels, + datasets: [ + { + data: values, + backgroundColor: this.colorPalette.slice(0, values.length), + borderColor: this.colorPalette.slice(0, values.length).map((c) => c.replace('0.8', '1')), + borderWidth: 1, + }, + ], + }; + } else { + return { + labels, + datasets: [ + { + label: valueColumn, + data: values, + backgroundColor: this.colorPalette[0], + borderColor: this.colorPalette[0].replace('0.8', '1'), + borderWidth: 1, + fill: this.widget.chart_type === 'line', + }, + ], + }; + } + }); + + protected chartOptions: ChartConfiguration['options'] = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: true, + position: 'top', + }, + }, + }; + + private colorPalette = [ + 'rgba(99, 102, 241, 0.8)', + 'rgba(168, 85, 247, 0.8)', + 'rgba(236, 72, 153, 0.8)', + 'rgba(244, 63, 94, 0.8)', + 'rgba(251, 146, 60, 0.8)', + 'rgba(234, 179, 8, 0.8)', + 'rgba(34, 197, 94, 0.8)', + 'rgba(6, 182, 212, 0.8)', + 'rgba(59, 130, 246, 0.8)', + 'rgba(139, 92, 246, 0.8)', + ]; + + constructor() { + effect(() => { + // This effect runs when widget input changes + if (this.widget?.query_id) { + this._loadData(); + } + }); + } + + get mappedChartType(): ChartJsType { + return (this.widget.chart_type || 'bar') as ChartJsType; + } + + private _loadData(): void { + if (!this.widget.query_id) { + this.error.set('No query linked to this widget'); + return; + } + + this.loading.set(true); + this.error.set(null); + + this._savedQueries.executeSavedQuery(this.connectionId, this.widget.query_id).subscribe({ + next: (result) => { + this.data.set(result.data); + this.loading.set(false); + }, + error: (err) => { + this.error.set(err?.error?.message || 'Failed to load data'); + this.loading.set(false); + }, + }); + } + + private _getFirstColumn(data: Record[]): string | null { + if (!data.length) return null; + const keys = Object.keys(data[0]); + return keys[0] || null; + } + + private _getSecondColumn(data: Record[]): string | null { + if (!data.length) return null; + const keys = Object.keys(data[0]); + return keys[1] || keys[0] || null; + } +} diff --git a/frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.css b/frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.css new file mode 100644 index 000000000..9039d6363 --- /dev/null +++ b/frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.css @@ -0,0 +1,69 @@ +:host { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; +} + +.loading-container, +.error-container, +.no-query-container, +.no-data-container, +.counter-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + text-align: center; +} + +.error-message { + color: #c62828; + margin: 0; +} + +@media (prefers-color-scheme: dark) { + .error-message { + color: #ef5350; + } +} + +.no-query-container p, +.no-data-container p { + margin: 0; + color: rgba(0, 0, 0, 0.54); +} + +@media (prefers-color-scheme: dark) { + .no-query-container p, + .no-data-container p { + color: rgba(255, 255, 255, 0.54); + } +} + +.hint { + font-size: 12px; + margin-top: 4px !important; +} + +.counter-value { + font-size: 48px; + font-weight: 700; + line-height: 1; + color: var(--mdc-filled-button-container-color, #1976d2); +} + +.counter-label { + font-size: 14px; + color: rgba(0, 0, 0, 0.54); + margin-top: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +@media (prefers-color-scheme: dark) { + .counter-label { + color: rgba(255, 255, 255, 0.54); + } +} diff --git a/frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.html b/frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.html new file mode 100644 index 000000000..2cc6326b3 --- /dev/null +++ b/frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.html @@ -0,0 +1,25 @@ +@if (loading()) { +
+ +
+} @else if (error()) { +
+

{{ error() }}

+
+} @else if (!widget.query_id) { +
+

No query linked to this counter widget.

+

Edit this widget to link a saved query.

+
+} @else if (counterValue() !== null) { +
+ {{ counterValue() }} + @if (label()) { + {{ label() }} + } +
+} @else { +
+

No data available

+
+} diff --git a/frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.spec.ts b/frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.spec.ts new file mode 100644 index 000000000..970645f01 --- /dev/null +++ b/frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.spec.ts @@ -0,0 +1,49 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { SavedQueriesService } from 'src/app/services/saved-queries.service'; +import { CounterWidgetComponent } from './counter-widget.component'; + +describe('CounterWidgetComponent', () => { + let component: CounterWidgetComponent; + let fixture: ComponentFixture; + let mockSavedQueriesService: Partial; + + beforeEach(async () => { + mockSavedQueriesService = { + executeSavedQuery: vi.fn(), + } as Partial; + + await TestBed.configureTestingModule({ + imports: [CounterWidgetComponent, BrowserAnimationsModule], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: SavedQueriesService, useValue: mockSavedQueriesService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(CounterWidgetComponent); + component = fixture.componentInstance; + component.widget = { + id: 'test-id', + name: 'Test Counter', + widget_type: 'counter', + description: null, + position_x: 0, + position_y: 0, + width: 2, + height: 2, + widget_options: {}, + query_id: null, + dashboard_id: 'test-dashboard', + }; + component.connectionId = 'test-conn'; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.ts b/frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.ts new file mode 100644 index 000000000..ba82ba3cd --- /dev/null +++ b/frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.ts @@ -0,0 +1,88 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, effect, Input, inject, signal } from '@angular/core'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { DashboardWidget } from 'src/app/models/dashboard'; +import { SavedQueriesService } from 'src/app/services/saved-queries.service'; + +@Component({ + selector: 'app-counter-widget', + templateUrl: './counter-widget.component.html', + styleUrls: ['./counter-widget.component.css'], + imports: [CommonModule, MatProgressSpinnerModule], +}) +export class CounterWidgetComponent { + @Input({ required: true }) widget!: DashboardWidget; + @Input({ required: true }) connectionId!: string; + + private _savedQueries = inject(SavedQueriesService); + + protected loading = signal(false); + protected error = signal(null); + protected data = signal[]>([]); + + protected counterValue = computed(() => { + const data = this.data(); + if (!data.length) return null; + + // Get the value column from widget options or use the first column + const valueColumn = (this.widget.widget_options?.['value_column'] as string) || Object.keys(data[0])[0]; + if (!valueColumn) return null; + + const value = data[0][valueColumn]; + if (typeof value === 'number') { + return this._formatNumber(value); + } + return String(value); + }); + + protected label = computed(() => { + const data = this.data(); + if (!data.length) return ''; + + const labelColumn = this.widget.widget_options?.['label_column'] as string; + if (labelColumn && data[0][labelColumn]) { + return String(data[0][labelColumn]); + } + + const valueColumn = (this.widget.widget_options?.['value_column'] as string) || Object.keys(data[0])[0]; + return valueColumn || ''; + }); + + constructor() { + effect(() => { + if (this.widget?.query_id) { + this._loadData(); + } + }); + } + + private _loadData(): void { + if (!this.widget.query_id) { + this.error.set('No query linked to this widget'); + return; + } + + this.loading.set(true); + this.error.set(null); + + this._savedQueries.executeSavedQuery(this.connectionId, this.widget.query_id).subscribe({ + next: (result) => { + this.data.set(result.data); + this.loading.set(false); + }, + error: (err) => { + this.error.set(err?.error?.message || 'Failed to load data'); + this.loading.set(false); + }, + }); + } + + private _formatNumber(value: number): string { + if (Math.abs(value) >= 1000000) { + return (value / 1000000).toFixed(1) + 'M'; + } else if (Math.abs(value) >= 1000) { + return (value / 1000).toFixed(1) + 'K'; + } + return value.toLocaleString(); + } +} diff --git a/frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.css b/frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.css new file mode 100644 index 000000000..cd2266e1e --- /dev/null +++ b/frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.css @@ -0,0 +1,75 @@ +:host { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; +} + +.loading-container, +.error-container, +.no-query-container, +.no-data-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + text-align: center; +} + +.error-message { + color: #c62828; + margin: 0; +} + +@media (prefers-color-scheme: dark) { + .error-message { + color: #ef5350; + } +} + +.no-query-container p, +.no-data-container p { + margin: 0; + color: rgba(0, 0, 0, 0.54); +} + +@media (prefers-color-scheme: dark) { + .no-query-container p, + .no-data-container p { + color: rgba(255, 255, 255, 0.54); + } +} + +.hint { + font-size: 12px; + margin-top: 4px !important; +} + +.table-container { + flex: 1; + overflow: auto; +} + +.table-container table { + width: 100%; +} + +.table-container th { + background: rgba(0, 0, 0, 0.04); + font-weight: 600; + white-space: nowrap; +} + +@media (prefers-color-scheme: dark) { + .table-container th { + background: rgba(255, 255, 255, 0.04); + } +} + +.table-container td { + white-space: nowrap; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.html b/frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.html new file mode 100644 index 000000000..ca742af84 --- /dev/null +++ b/frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.html @@ -0,0 +1,31 @@ +@if (loading()) { +
+ +
+} @else if (error()) { +
+

{{ error() }}

+
+} @else if (!widget.query_id) { +
+

No query linked to this table widget.

+

Edit this widget to link a saved query.

+
+} @else if (data().length > 0) { +
+ + @for (column of columns(); track column) { + + + + + } + + +
{{ column }}{{ row[column] }}
+
+} @else { +
+

No data available

+
+} diff --git a/frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.spec.ts b/frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.spec.ts new file mode 100644 index 000000000..c05ec23d8 --- /dev/null +++ b/frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.spec.ts @@ -0,0 +1,49 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { SavedQueriesService } from 'src/app/services/saved-queries.service'; +import { TableWidgetComponent } from './table-widget.component'; + +describe('TableWidgetComponent', () => { + let component: TableWidgetComponent; + let fixture: ComponentFixture; + let mockSavedQueriesService: Partial; + + beforeEach(async () => { + mockSavedQueriesService = { + executeSavedQuery: vi.fn(), + } as Partial; + + await TestBed.configureTestingModule({ + imports: [TableWidgetComponent, BrowserAnimationsModule], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: SavedQueriesService, useValue: mockSavedQueriesService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(TableWidgetComponent); + component = fixture.componentInstance; + component.widget = { + id: 'test-id', + name: 'Test Table', + widget_type: 'table', + description: null, + position_x: 0, + position_y: 0, + width: 4, + height: 4, + widget_options: {}, + query_id: null, + dashboard_id: 'test-dashboard', + }; + component.connectionId = 'test-conn'; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.ts b/frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.ts new file mode 100644 index 000000000..6c0cbaa48 --- /dev/null +++ b/frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.ts @@ -0,0 +1,58 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, effect, Input, inject, signal } from '@angular/core'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTableModule } from '@angular/material/table'; +import { DashboardWidget } from 'src/app/models/dashboard'; +import { SavedQueriesService } from 'src/app/services/saved-queries.service'; + +@Component({ + selector: 'app-table-widget', + templateUrl: './table-widget.component.html', + styleUrls: ['./table-widget.component.css'], + imports: [CommonModule, MatTableModule, MatProgressSpinnerModule], +}) +export class TableWidgetComponent { + @Input({ required: true }) widget!: DashboardWidget; + @Input({ required: true }) connectionId!: string; + + private _savedQueries = inject(SavedQueriesService); + + protected loading = signal(false); + protected error = signal(null); + protected data = signal[]>([]); + + protected columns = computed(() => { + const data = this.data(); + if (!data.length) return []; + return Object.keys(data[0]); + }); + + constructor() { + effect(() => { + if (this.widget?.query_id) { + this._loadData(); + } + }); + } + + private _loadData(): void { + if (!this.widget.query_id) { + this.error.set('No query linked to this widget'); + return; + } + + this.loading.set(true); + this.error.set(null); + + this._savedQueries.executeSavedQuery(this.connectionId, this.widget.query_id).subscribe({ + next: (result) => { + this.data.set(result.data); + this.loading.set(false); + }, + error: (err) => { + this.error.set(err?.error?.message || 'Failed to load data'); + this.loading.set(false); + }, + }); + } +} diff --git a/frontend/src/app/components/dashboards/widget-renderers/text-widget/text-widget.component.css b/frontend/src/app/components/dashboards/widget-renderers/text-widget/text-widget.component.css new file mode 100644 index 000000000..5acc2048e --- /dev/null +++ b/frontend/src/app/components/dashboards/widget-renderers/text-widget/text-widget.component.css @@ -0,0 +1,94 @@ +:host { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; +} + +.text-container { + flex: 1; + overflow: auto; +} + +.text-container ::ng-deep { + h1, + h2, + h3, + h4, + h5, + h6 { + margin-top: 0; + margin-bottom: 8px; + } + + p { + margin: 0 0 8px 0; + } + + p:last-child { + margin-bottom: 0; + } + + ul, + ol { + margin: 0 0 8px 0; + padding-left: 20px; + } + + a { + color: var(--mdc-filled-button-container-color, #1976d2); + } + + code { + background: rgba(0, 0, 0, 0.05); + padding: 2px 6px; + border-radius: 4px; + font-family: monospace; + } + + pre { + background: rgba(0, 0, 0, 0.05); + padding: 12px; + border-radius: 4px; + overflow: auto; + } + + pre code { + background: none; + padding: 0; + } +} + +@media (prefers-color-scheme: dark) { + .text-container ::ng-deep { + code, + pre { + background: rgba(255, 255, 255, 0.05); + } + } +} + +.no-content-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + text-align: center; +} + +.no-content-container p { + margin: 0; + color: rgba(0, 0, 0, 0.54); +} + +@media (prefers-color-scheme: dark) { + .no-content-container p { + color: rgba(255, 255, 255, 0.54); + } +} + +.hint { + font-size: 12px; + margin-top: 4px !important; +} diff --git a/frontend/src/app/components/dashboards/widget-renderers/text-widget/text-widget.component.html b/frontend/src/app/components/dashboards/widget-renderers/text-widget/text-widget.component.html new file mode 100644 index 000000000..f3c41da3c --- /dev/null +++ b/frontend/src/app/components/dashboards/widget-renderers/text-widget/text-widget.component.html @@ -0,0 +1,10 @@ +@if (textContent()) { +
+ +
+} @else { +
+

No content

+

Edit this widget to add text content.

+
+} diff --git a/frontend/src/app/components/dashboards/widget-renderers/text-widget/text-widget.component.spec.ts b/frontend/src/app/components/dashboards/widget-renderers/text-widget/text-widget.component.spec.ts new file mode 100644 index 000000000..361d78375 --- /dev/null +++ b/frontend/src/app/components/dashboards/widget-renderers/text-widget/text-widget.component.spec.ts @@ -0,0 +1,36 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { MarkdownModule } from 'ngx-markdown'; +import { TextWidgetComponent } from './text-widget.component'; + +describe('TextWidgetComponent', () => { + let component: TextWidgetComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TextWidgetComponent, BrowserAnimationsModule, MarkdownModule.forRoot()], + }).compileComponents(); + + fixture = TestBed.createComponent(TextWidgetComponent); + component = fixture.componentInstance; + component.widget = { + id: 'test-id', + name: 'Test Text', + widget_type: 'text', + description: null, + position_x: 0, + position_y: 0, + width: 4, + height: 4, + widget_options: { text_content: '# Hello World' }, + query_id: null, + dashboard_id: 'test-dashboard', + }; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/components/dashboards/widget-renderers/text-widget/text-widget.component.ts b/frontend/src/app/components/dashboards/widget-renderers/text-widget/text-widget.component.ts new file mode 100644 index 000000000..302938724 --- /dev/null +++ b/frontend/src/app/components/dashboards/widget-renderers/text-widget/text-widget.component.ts @@ -0,0 +1,18 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, Input } from '@angular/core'; +import { MarkdownModule } from 'ngx-markdown'; +import { DashboardWidget } from 'src/app/models/dashboard'; + +@Component({ + selector: 'app-text-widget', + templateUrl: './text-widget.component.html', + styleUrls: ['./text-widget.component.css'], + imports: [CommonModule, MarkdownModule], +}) +export class TextWidgetComponent { + @Input({ required: true }) widget!: DashboardWidget; + + protected textContent = computed(() => { + return (this.widget.widget_options?.['text_content'] as string) || ''; + }); +} diff --git a/frontend/src/app/models/dashboard.ts b/frontend/src/app/models/dashboard.ts new file mode 100644 index 000000000..c51d218ca --- /dev/null +++ b/frontend/src/app/models/dashboard.ts @@ -0,0 +1,62 @@ +export type DashboardWidgetType = 'table' | 'chart' | 'counter' | 'text'; + +export interface Dashboard { + id: string; + name: string; + description: string | null; + connection_id: string; + created_at: string; + updated_at: string; + widgets?: DashboardWidget[]; +} + +export interface DashboardWidget { + id: string; + widget_type: DashboardWidgetType; + chart_type?: string; + name: string; + description: string | null; + position_x: number; + position_y: number; + width: number; + height: number; + widget_options: Record; + query_id: string | null; + dashboard_id: string; +} + +export interface CreateDashboardPayload { + name: string; + description?: string; +} + +export interface UpdateDashboardPayload { + name?: string; + description?: string; +} + +export interface CreateWidgetPayload { + widget_type: DashboardWidgetType; + chart_type?: string; + name: string; + description?: string; + position_x: number; + position_y: number; + width: number; + height: number; + widget_options?: Record; + query_id?: string; +} + +export interface UpdateWidgetPayload { + widget_type?: DashboardWidgetType; + chart_type?: string; + name?: string; + description?: string; + position_x?: number; + position_y?: number; + width?: number; + height?: number; + widget_options?: Record; + query_id?: string; +} diff --git a/frontend/src/app/services/connections.service.spec.ts b/frontend/src/app/services/connections.service.spec.ts index 1c51e304c..d6ebf7dd0 100644 --- a/frontend/src/app/services/connections.service.spec.ts +++ b/frontend/src/app/services/connections.service.spec.ts @@ -253,18 +253,18 @@ describe('ConnectionsService', () => { expect(service.currentTab).toEqual('dashboard'); }); - it('should get visible tabs dashboard, charts and audit in any case', () => { - expect(service.visibleTabs).toEqual(['dashboard', 'charts', 'audit']); + it('should get visible tabs dashboard, dashboards and audit in any case', () => { + expect(service.visibleTabs).toEqual(['dashboard', 'dashboards', 'audit']); }); - it('should get visible tabs dashboard, charts, audit and permissions if groupsAccessLevel is true', () => { + it('should get visible tabs dashboard, dashboards, audit and permissions if groupsAccessLevel is true', () => { service.groupsAccessLevel = true; - expect(service.visibleTabs).toEqual(['dashboard', 'charts', 'audit', 'permissions']); + expect(service.visibleTabs).toEqual(['dashboard', 'dashboards', 'audit', 'permissions']); }); - it('should get visible tabs dashboard, charts, audit, connection-settings and edit-db if connectionAccessLevel is edit', () => { + it('should get visible tabs dashboard, dashboards, audit, connection-settings and edit-db if connectionAccessLevel is edit', () => { service.connectionAccessLevel = AccessLevel.Edit; - expect(service.visibleTabs).toEqual(['dashboard', 'charts', 'audit', 'connection-settings', 'edit-db']); + expect(service.visibleTabs).toEqual(['dashboard', 'dashboards', 'audit', 'connection-settings', 'edit-db']); }); it('should call fetchConnections', () => { diff --git a/frontend/src/app/services/connections.service.ts b/frontend/src/app/services/connections.service.ts index 160f60b37..fdbfbbfae 100644 --- a/frontend/src/app/services/connections.service.ts +++ b/frontend/src/app/services/connections.service.ts @@ -117,7 +117,7 @@ export class ConnectionsService { } get visibleTabs() { - let tabs = ['dashboard', 'charts', 'audit']; + let tabs = ['dashboard', 'dashboards', 'audit']; if (this.groupsAccessLevel) tabs.push('permissions'); if (this.isPermitted(this.connectionAccessLevel)) tabs.push('connection-settings', 'edit-db'); return tabs; diff --git a/frontend/src/app/services/dashboards.service.ts b/frontend/src/app/services/dashboards.service.ts new file mode 100644 index 000000000..206fb21f5 --- /dev/null +++ b/frontend/src/app/services/dashboards.service.ts @@ -0,0 +1,193 @@ +import { HttpClient } from '@angular/common/http'; +import { computed, Injectable, inject, signal } from '@angular/core'; +import { rxResource } from '@angular/core/rxjs-interop'; +import { EMPTY, Observable } from 'rxjs'; +import { catchError, tap } from 'rxjs/operators'; +import { + CreateDashboardPayload, + CreateWidgetPayload, + Dashboard, + DashboardWidget, + UpdateDashboardPayload, + UpdateWidgetPayload, +} from '../models/dashboard'; +import { NotificationsService } from './notifications.service'; + +export type DashboardUpdateEvent = 'created' | 'updated' | 'deleted' | ''; + +@Injectable({ + providedIn: 'root', +}) +export class DashboardsService { + private _http = inject(HttpClient); + private _notifications = inject(NotificationsService); + + private _dashboardsUpdated = signal(''); + public readonly dashboardsUpdated = this._dashboardsUpdated.asReadonly(); + + // Active connection for reactive fetching of dashboards list + private _activeConnectionId = signal(null); + + // Active dashboard for reactive fetching of single dashboard + private _activeDashboardId = signal(null); + + // Resource for dashboards list + private _dashboardsResource = rxResource({ + params: () => this._activeConnectionId(), + stream: ({ params: connectionId }) => { + if (!connectionId) return EMPTY; + return this._http.get(`/dashboards/${connectionId}`).pipe( + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error?.message || 'Failed to fetch dashboards'); + return EMPTY; + }), + ); + }, + }); + + // Resource for single dashboard with widgets + private _dashboardResource = rxResource({ + params: () => ({ connectionId: this._activeConnectionId(), dashboardId: this._activeDashboardId() }), + stream: ({ params }) => { + if (!params.connectionId || !params.dashboardId) return EMPTY; + return this._http.get(`/dashboard/${params.dashboardId}/${params.connectionId}`).pipe( + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error?.message || 'Failed to fetch dashboard'); + return EMPTY; + }), + ); + }, + }); + + // Computed signals for convenient access + public readonly dashboards = computed(() => this._dashboardsResource.value() ?? []); + public readonly dashboardsLoading = computed(() => this._dashboardsResource.isLoading()); + public readonly dashboardsError = computed(() => this._dashboardsResource.error() as Error | null); + + public readonly dashboard = computed(() => this._dashboardResource.value() ?? null); + public readonly dashboardLoading = computed(() => this._dashboardResource.isLoading()); + public readonly dashboardError = computed(() => this._dashboardResource.error() as Error | null); + + // Methods to control resource + setActiveConnection(connectionId: string): void { + this._activeConnectionId.set(connectionId); + } + + setActiveDashboard(dashboardId: string | null): void { + this._activeDashboardId.set(dashboardId); + } + + refreshDashboards(): void { + this._dashboardsResource.reload(); + } + + refreshDashboard(): void { + this._dashboardResource.reload(); + } + + // Dashboard CRUD operations + createDashboard(connectionId: string, payload: CreateDashboardPayload): Observable { + return this._http.post(`/dashboards/${connectionId}`, payload).pipe( + tap(() => { + this._notifications.showSuccessSnackbar('Dashboard created successfully'); + this._dashboardsUpdated.set('created'); + }), + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error?.message || 'Failed to create dashboard'); + return EMPTY; + }), + ); + } + + updateDashboard(connectionId: string, dashboardId: string, payload: UpdateDashboardPayload): Observable { + return this._http.put(`/dashboard/${dashboardId}/${connectionId}`, payload).pipe( + tap(() => { + this._notifications.showSuccessSnackbar('Dashboard updated successfully'); + this._dashboardsUpdated.set('updated'); + }), + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error?.message || 'Failed to update dashboard'); + return EMPTY; + }), + ); + } + + deleteDashboard(connectionId: string, dashboardId: string): Observable { + return this._http.delete(`/dashboard/${dashboardId}/${connectionId}`).pipe( + tap(() => { + this._notifications.showSuccessSnackbar('Dashboard deleted successfully'); + this._dashboardsUpdated.set('deleted'); + }), + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error?.message || 'Failed to delete dashboard'); + return EMPTY; + }), + ); + } + + // Widget CRUD operations + createWidget(connectionId: string, dashboardId: string, payload: CreateWidgetPayload): Observable { + return this._http.post(`/dashboard/${dashboardId}/widget/${connectionId}`, payload).pipe( + tap(() => { + this._notifications.showSuccessSnackbar('Widget created successfully'); + }), + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error?.message || 'Failed to create widget'); + return EMPTY; + }), + ); + } + + updateWidget( + connectionId: string, + dashboardId: string, + widgetId: string, + payload: UpdateWidgetPayload, + ): Observable { + return this._http + .put(`/dashboard/${dashboardId}/widget/${widgetId}/${connectionId}`, payload) + .pipe( + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error?.message || 'Failed to update widget'); + return EMPTY; + }), + ); + } + + updateWidgetPosition( + connectionId: string, + dashboardId: string, + widgetId: string, + payload: Pick, + ): Observable { + return this._http + .put(`/dashboard/${dashboardId}/widget/${widgetId}/${connectionId}`, payload) + .pipe( + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error?.message || 'Failed to update widget position'); + return EMPTY; + }), + ); + } + + deleteWidget(connectionId: string, dashboardId: string, widgetId: string): Observable { + return this._http.delete(`/dashboard/${dashboardId}/widget/${widgetId}/${connectionId}`).pipe( + tap(() => { + this._notifications.showSuccessSnackbar('Widget deleted successfully'); + }), + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error?.message || 'Failed to delete widget'); + return EMPTY; + }), + ); + } +} diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 8ffe1362c..89e63b7ba 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -5868,6 +5868,19 @@ __metadata: languageName: node linkType: hard +"angular-gridster2@npm:^21.0.1": + version: 21.0.1 + resolution: "angular-gridster2@npm:21.0.1" + dependencies: + tslib: ^2.4.0 + peerDependencies: + "@angular/common": ^21.0.0 + "@angular/core": ^21.0.0 + rxjs: ^7.0.0 + checksum: e40d09a1c55dc25f05078d25b060d4c7b221993d5bbf7df7c799bb837eec749f6333a40ac8c74b0fad1dad3d282089d50bfdefb92e500c607f8916a2bff8f422 + languageName: node + linkType: hard + "angular-password-strength-meter@npm:@eresearchqut/angular-password-strength-meter@^13.0.7": version: 13.0.7 resolution: "@eresearchqut/angular-password-strength-meter@npm:13.0.7" @@ -11712,6 +11725,7 @@ __metadata: "@zxcvbn-ts/core": ^3.0.4 "@zxcvbn-ts/language-en": ^3.0.2 amplitude-js: ^8.21.9 + angular-gridster2: ^21.0.1 angular-password-strength-meter: "npm:@eresearchqut/angular-password-strength-meter@^13.0.7" angulartics2: ^14.1.0 chart.js: ^4.5.1 From d87cb28307631d3bcf554bc689cf75c0bde6747a Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Tue, 3 Feb 2026 20:10:18 +0000 Subject: [PATCH 2/5] feat: refactor widget structure and add chart label type option - Refactor widget to only contain positioning data (position_x, position_y, width, height, query_id) - Move widget display properties (widget_type, chart_type, widget_options) to SavedQuery - Add dashboard-widget component to handle data fetching and widget rendering - Display widget name from saved query in widget header - Add drag and resize support for widgets in edit mode using angular-gridster2 v20 - Add "Label Type" option for bar/line charts with "Values" and "Datetime" options - Update all widget renderer tests to use new structure with preloaded data Co-Authored-By: Claude Opus 4.5 --- frontend/package.json | 2 +- .../chart-delete-dialog.component.spec.ts | 3 + .../chart-edit/chart-edit.component.html | 12 +- .../chart-edit/chart-edit.component.spec.ts | 6 + .../charts/chart-edit/chart-edit.component.ts | 38 ++++ .../chart-preview/chart-preview.component.ts | 36 +++- .../charts-list/charts-list.component.spec.ts | 3 + .../dashboard-view.component.css | 149 +++++++++++---- .../dashboard-view.component.html | 110 +++++------ .../dashboard-view.component.ts | 86 +++++---- .../widget-delete-dialog.component.html | 2 +- .../widget-edit-dialog.component.css | 7 +- .../widget-edit-dialog.component.html | 42 +---- .../widget-edit-dialog.component.spec.ts | 173 ++++++++++++++++-- .../widget-edit-dialog.component.ts | 58 ++---- .../chart-widget/chart-widget.component.html | 15 +- .../chart-widget.component.spec.ts | 47 +++-- .../chart-widget/chart-widget.component.ts | 100 +++++----- .../counter-widget.component.html | 15 +- .../counter-widget.component.spec.ts | 37 ++-- .../counter-widget.component.ts | 62 +++---- .../dashboard-widget.component.css | 23 +++ .../dashboard-widget.component.html | 47 +++++ .../dashboard-widget.component.ts | 75 ++++++++ .../table-widget/table-widget.component.html | 15 +- .../table-widget.component.spec.ts | 45 +++-- .../table-widget/table-widget.component.ts | 41 +---- .../text-widget/text-widget.component.spec.ts | 21 ++- .../text-widget/text-widget.component.ts | 8 +- frontend/src/app/models/dashboard.ts | 19 +- frontend/src/app/models/saved-query.ts | 15 +- frontend/yarn.lock | 14 +- 32 files changed, 858 insertions(+), 468 deletions(-) create mode 100644 frontend/src/app/components/dashboards/widget-renderers/dashboard-widget/dashboard-widget.component.css create mode 100644 frontend/src/app/components/dashboards/widget-renderers/dashboard-widget/dashboard-widget.component.html create mode 100644 frontend/src/app/components/dashboards/widget-renderers/dashboard-widget/dashboard-widget.component.ts diff --git a/frontend/package.json b/frontend/package.json index 4c489b93f..4ffd6a5ac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,7 +36,7 @@ "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-en": "^3.0.2", "amplitude-js": "^8.21.9", - "angular-gridster2": "^21.0.1", + "angular-gridster2": "^20.0.0", "angular-password-strength-meter": "npm:@eresearchqut/angular-password-strength-meter@^13.0.7", "angulartics2": "^14.1.0", "chart.js": "^4.5.1", diff --git a/frontend/src/app/components/charts/chart-delete-dialog/chart-delete-dialog.component.spec.ts b/frontend/src/app/components/charts/chart-delete-dialog/chart-delete-dialog.component.spec.ts index 01ac2695f..2fd8ec25c 100644 --- a/frontend/src/app/components/charts/chart-delete-dialog/chart-delete-dialog.component.spec.ts +++ b/frontend/src/app/components/charts/chart-delete-dialog/chart-delete-dialog.component.spec.ts @@ -25,6 +25,9 @@ describe('ChartDeleteDialogComponent', () => { id: '1', name: 'Test Query', description: 'Test description', + widget_type: 'chart', + chart_type: 'bar', + widget_options: null, query_text: 'SELECT * FROM users', connection_id: 'conn-1', created_at: '2024-01-01', diff --git a/frontend/src/app/components/charts/chart-edit/chart-edit.component.html b/frontend/src/app/components/charts/chart-edit/chart-edit.component.html index 42358bdc6..0d4f1c502 100644 --- a/frontend/src/app/components/charts/chart-edit/chart-edit.component.html +++ b/frontend/src/app/components/charts/chart-edit/chart-edit.component.html @@ -84,6 +84,15 @@

Chart Preview

+ + Label Type + + + {{ type.label }} + + + + Value Column @@ -99,7 +108,8 @@

Chart Preview

[chartType]="chartType()" [data]="testResults()" [labelColumn]="labelColumn()" - [valueColumn]="valueColumn()"> + [valueColumn]="valueColumn()" + [labelType]="labelType()"> diff --git a/frontend/src/app/components/charts/chart-edit/chart-edit.component.spec.ts b/frontend/src/app/components/charts/chart-edit/chart-edit.component.spec.ts index 58df8413b..08429d6c9 100644 --- a/frontend/src/app/components/charts/chart-edit/chart-edit.component.spec.ts +++ b/frontend/src/app/components/charts/chart-edit/chart-edit.component.spec.ts @@ -46,6 +46,9 @@ describe('ChartEditComponent', () => { id: '1', name: 'Test Query', description: 'Test description', + widget_type: 'chart', + chart_type: 'bar', + widget_options: { label_column: 'name', value_column: 'count' }, query_text: 'SELECT * FROM users', connection_id: 'conn-1', created_at: '2024-01-01', @@ -228,6 +231,9 @@ describe('ChartEditComponent', () => { name: 'New Query', description: undefined, query_text: 'SELECT 1', + widget_type: 'chart', + chart_type: 'bar', + widget_options: undefined, }); }); diff --git a/frontend/src/app/components/charts/chart-edit/chart-edit.component.ts b/frontend/src/app/components/charts/chart-edit/chart-edit.component.ts index 1e3b216cc..03329e70d 100644 --- a/frontend/src/app/components/charts/chart-edit/chart-edit.component.ts +++ b/frontend/src/app/components/charts/chart-edit/chart-edit.component.ts @@ -63,6 +63,7 @@ export class ChartEditComponent implements OnInit { protected chartType = signal('bar'); protected labelColumn = signal(''); protected valueColumn = signal(''); + protected labelType = signal<'values' | 'datetime'>('values'); public chartTypes: { value: ChartType; label: string }[] = [ { value: 'bar', label: 'Bar Chart' }, @@ -72,6 +73,13 @@ export class ChartEditComponent implements OnInit { { value: 'polarArea', label: 'Polar Area Chart' }, ]; + public labelTypes: { value: 'values' | 'datetime'; label: string }[] = [ + { value: 'values', label: 'Values' }, + { value: 'datetime', label: 'Datetime' }, + ]; + + protected showLabelTypeOption = computed(() => ['bar', 'line'].includes(this.chartType())); + // Use a signal for codeModel to ensure change detection works on load // Only update this signal when loading a query, not during typing (to preserve cursor position) protected codeModel = signal({ @@ -140,6 +148,21 @@ export class ChartEditComponent implements OnInit { this.queryName.set(query.name); this.queryDescription.set(query.description || ''); this.queryText.set(query.query_text); + // Load chart configuration + if (query.chart_type) { + this.chartType.set(query.chart_type); + } + if (query.widget_options) { + if (query.widget_options['label_column']) { + this.labelColumn.set(query.widget_options['label_column'] as string); + } + if (query.widget_options['value_column']) { + this.valueColumn.set(query.widget_options['value_column'] as string); + } + if (query.widget_options['label_type']) { + this.labelType.set(query.widget_options['label_type'] as 'values' | 'datetime'); + } + } // Set codeModel value for Monaco editor (only on load, not during typing) this.codeModel.set({ language: 'sql', uri: 'query.sql', value: query.query_text }); // Automatically test the query to show chart preview @@ -190,10 +213,25 @@ export class ChartEditComponent implements OnInit { this.saving.set(true); + // Build widget_options with column selections + const widgetOptions: Record = {}; + if (this.labelColumn()) { + widgetOptions['label_column'] = this.labelColumn(); + } + if (this.valueColumn()) { + widgetOptions['value_column'] = this.valueColumn(); + } + if (this.labelType() && this.showLabelTypeOption()) { + widgetOptions['label_type'] = this.labelType(); + } + const payload = { name: this.queryName(), description: this.queryDescription() || undefined, query_text: this.queryText(), + widget_type: 'chart' as const, + chart_type: this.chartType(), + widget_options: Object.keys(widgetOptions).length > 0 ? widgetOptions : undefined, }; if (this.isEditMode()) { diff --git a/frontend/src/app/components/charts/chart-preview/chart-preview.component.ts b/frontend/src/app/components/charts/chart-preview/chart-preview.component.ts index e7f4dc118..9e6ead823 100644 --- a/frontend/src/app/components/charts/chart-preview/chart-preview.component.ts +++ b/frontend/src/app/components/charts/chart-preview/chart-preview.component.ts @@ -15,6 +15,7 @@ export class ChartPreviewComponent implements OnChanges { @Input() data: Record[] = []; @Input() labelColumn = ''; @Input() valueColumn = ''; + @Input() labelType: 'values' | 'datetime' = 'values'; public chartData: ChartData | null = null; public chartOptions: ChartConfiguration['options'] = { @@ -42,7 +43,13 @@ export class ChartPreviewComponent implements OnChanges { ]; ngOnChanges(changes: SimpleChanges): void { - if (changes['data'] || changes['labelColumn'] || changes['valueColumn'] || changes['chartType']) { + if ( + changes['data'] || + changes['labelColumn'] || + changes['valueColumn'] || + changes['chartType'] || + changes['labelType'] + ) { this.updateChartData(); } } @@ -57,7 +64,13 @@ export class ChartPreviewComponent implements OnChanges { return; } - const labels = this.data.map((row) => String(row[this.labelColumn] ?? '')); + const labels = this.data.map((row) => { + const val = row[this.labelColumn]; + if (this.labelType === 'datetime' && val) { + return this.formatDatetime(val); + } + return String(val ?? ''); + }); const values = this.data.map((row) => { const val = row[this.valueColumn]; return typeof val === 'number' ? val : parseFloat(String(val)) || 0; @@ -93,4 +106,23 @@ export class ChartPreviewComponent implements OnChanges { }; } } + + private formatDatetime(value: unknown): string { + if (!value) return ''; + + try { + const date = new Date(value as string | number | Date); + if (isNaN(date.getTime())) { + return String(value); + } + // Format as localized date string + return date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + } catch { + return String(value); + } + } } diff --git a/frontend/src/app/components/charts/charts-list/charts-list.component.spec.ts b/frontend/src/app/components/charts/charts-list/charts-list.component.spec.ts index 686340bd0..f42d8e04b 100644 --- a/frontend/src/app/components/charts/charts-list/charts-list.component.spec.ts +++ b/frontend/src/app/components/charts/charts-list/charts-list.component.spec.ts @@ -36,6 +36,9 @@ describe('ChartsListComponent', () => { id: '1', name: 'Test Query', description: 'Test description', + widget_type: 'chart', + chart_type: 'bar', + widget_options: null, query_text: 'SELECT * FROM users', connection_id: 'conn-1', created_at: '2024-01-01', diff --git a/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.css b/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.css index 0f5bb952f..596df87b5 100644 --- a/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.css +++ b/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.css @@ -1,11 +1,13 @@ -.dashboard-view-page { +/* Scoped to app-dashboard-view to prevent style leakage with ViewEncapsulation.None */ + +app-dashboard-view .dashboard-view-page { padding: 24px; height: calc(100vh - 64px); display: flex; flex-direction: column; } -.dashboard-header { +app-dashboard-view .dashboard-header { display: flex; align-items: center; justify-content: space-between; @@ -14,48 +16,48 @@ gap: 16px; } -.header-left { +app-dashboard-view .header-left { display: flex; align-items: center; gap: 8px; } -.header-info { +app-dashboard-view .header-info { display: flex; flex-direction: column; } -.header-info h1 { +app-dashboard-view .header-info h1 { margin: 0; line-height: 1.2; } -.dashboard-description { +app-dashboard-view .dashboard-description { margin: 4px 0 0 0; color: rgba(0, 0, 0, 0.64); font-size: 14px; } @media (prefers-color-scheme: dark) { - .dashboard-description { + app-dashboard-view .dashboard-description { color: rgba(255, 255, 255, 0.7); } } -.header-actions { +app-dashboard-view .header-actions { display: flex; align-items: center; gap: 16px; } -.loading-container { +app-dashboard-view .loading-container { display: flex; justify-content: center; align-items: center; flex: 1; } -.no-widgets { +app-dashboard-view .no-widgets { display: flex; flex-direction: column; align-items: center; @@ -68,12 +70,12 @@ } @media (prefers-color-scheme: dark) { - .no-widgets { + app-dashboard-view .no-widgets { background-color: rgba(255, 255, 255, 0.05); } } -.no-widgets-icon { +app-dashboard-view .no-widgets-icon { font-size: 64px; width: 64px; height: 64px; @@ -82,46 +84,46 @@ } @media (prefers-color-scheme: dark) { - .no-widgets-icon { + app-dashboard-view .no-widgets-icon { color: rgba(255, 255, 255, 0.3); } } -.no-widgets h3 { +app-dashboard-view .no-widgets h3 { margin: 0 0 8px 0; color: rgba(0, 0, 0, 0.87); } @media (prefers-color-scheme: dark) { - .no-widgets h3 { + app-dashboard-view .no-widgets h3 { color: rgba(255, 255, 255, 0.87); } } -.no-widgets p { +app-dashboard-view .no-widgets p { margin: 0 0 16px 0; color: rgba(0, 0, 0, 0.54); } @media (prefers-color-scheme: dark) { - .no-widgets p { + app-dashboard-view .no-widgets p { color: rgba(255, 255, 255, 0.54); } } -.dashboard-grid { +app-dashboard-view .dashboard-grid { flex: 1; background-color: rgba(0, 0, 0, 0.02); border-radius: 8px; } @media (prefers-color-scheme: dark) { - .dashboard-grid { + app-dashboard-view .dashboard-grid { background-color: rgba(255, 255, 255, 0.02); } } -.widget-item { +app-dashboard-view .widget-item { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); @@ -131,18 +133,99 @@ } @media (prefers-color-scheme: dark) { - .widget-item { + app-dashboard-view .widget-item { background: #1e1e1e; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); } } -.widget-item.edit-mode { - cursor: move; +app-dashboard-view .widget-item.edit-mode { border: 2px dashed var(--mdc-filled-button-container-color, #1976d2); } -.widget-header { +app-dashboard-view .widget-item.edit-mode .widget-header { + cursor: move; +} + +/* Gridster resize handles - gridster controls visibility via resizable.enabled */ +app-dashboard-view gridster-item .gridster-item-resizable-handler { + position: absolute; + z-index: 100; +} + +app-dashboard-view gridster-item .gridster-item-resizable-handler.handle-s { + bottom: -4px; + left: 25%; + width: 50%; + height: 8px; + cursor: ns-resize; +} + +app-dashboard-view gridster-item .gridster-item-resizable-handler.handle-e { + right: -4px; + top: 25%; + width: 8px; + height: 50%; + cursor: ew-resize; +} + +app-dashboard-view gridster-item .gridster-item-resizable-handler.handle-n { + top: -4px; + left: 25%; + width: 50%; + height: 8px; + cursor: ns-resize; +} + +app-dashboard-view gridster-item .gridster-item-resizable-handler.handle-w { + left: -4px; + top: 25%; + width: 8px; + height: 50%; + cursor: ew-resize; +} + +app-dashboard-view gridster-item .gridster-item-resizable-handler.handle-se { + bottom: -4px; + right: -4px; + width: 16px; + height: 16px; + cursor: se-resize; + background: var(--mdc-filled-button-container-color, #1976d2); + border-radius: 2px 0 6px 0; +} + +app-dashboard-view gridster-item .gridster-item-resizable-handler.handle-ne { + top: -4px; + right: -4px; + width: 16px; + height: 16px; + cursor: ne-resize; + background: var(--mdc-filled-button-container-color, #1976d2); + border-radius: 0 6px 0 2px; +} + +app-dashboard-view gridster-item .gridster-item-resizable-handler.handle-sw { + bottom: -4px; + left: -4px; + width: 16px; + height: 16px; + cursor: sw-resize; + background: var(--mdc-filled-button-container-color, #1976d2); + border-radius: 0 2px 0 6px; +} + +app-dashboard-view gridster-item .gridster-item-resizable-handler.handle-nw { + top: -4px; + left: -4px; + width: 16px; + height: 16px; + cursor: nw-resize; + background: var(--mdc-filled-button-container-color, #1976d2); + border-radius: 6px 0 2px 0; +} + +app-dashboard-view .widget-header { display: flex; align-items: center; justify-content: space-between; @@ -152,12 +235,12 @@ } @media (prefers-color-scheme: dark) { - .widget-header { + app-dashboard-view .widget-header { border-bottom-color: rgba(255, 255, 255, 0.12); } } -.widget-title { +app-dashboard-view .widget-title { font-weight: 500; font-size: 14px; overflow: hidden; @@ -165,11 +248,11 @@ white-space: nowrap; } -.widget-menu-button { +app-dashboard-view .widget-menu-button { flex-shrink: 0; } -.widget-content { +app-dashboard-view .widget-content { flex: 1; padding: 16px; overflow: auto; @@ -177,27 +260,27 @@ flex-direction: column; } -.delete-action { +app-dashboard-view .delete-action { color: #c62828; } @media (prefers-color-scheme: dark) { - .delete-action { + app-dashboard-view .delete-action { color: #ef5350; } } @media (width <= 600px) { - .dashboard-view-page { + app-dashboard-view .dashboard-view-page { padding: 16px; } - .dashboard-header { + app-dashboard-view .dashboard-header { flex-direction: column; align-items: flex-start; } - .header-actions { + app-dashboard-view .header-actions { width: 100%; justify-content: space-between; } diff --git a/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.html b/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.html index ac6b742e9..5a13edf78 100644 --- a/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.html +++ b/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.html @@ -8,9 +8,9 @@

{{ dashboard()?.name || 'Loading...' }}

-

- {{ dashboard()?.description }} -

+ @if (dashboard()?.description) { +

{{ dashboard()?.description }}

+ }
@@ -30,66 +30,52 @@

{{ dashboard()?.name || 'Loading...' }}

-
- -
+ @if (loading()) { +
+ +
+ } -
- bar_chart -

No charts yet

-

Add charts to visualize your data from saved queries.

- -
+ @if (!loading() && gridsterItems().length === 0) { +
+ bar_chart +

No charts yet

+

Add charts to visualize your data from saved queries.

+ +
+ } - - @for (item of gridsterItems(); track item.widget.id) { - -
- {{ item.widget.name }} - - - - - -
-
- @switch (item.widget.widget_type) { - @case ('chart') { - - - } - @case ('table') { - - - } - @case ('counter') { - - - } - @case ('text') { - - - } - } -
-
- } -
+ + + + + +
+ + +
+ + } + + } diff --git a/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.ts b/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.ts index 95f90302a..df09e459a 100644 --- a/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.ts +++ b/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, computed, effect, inject, OnInit, signal } from '@angular/core'; +import { Component, computed, effect, inject, OnInit, signal, ViewEncapsulation } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; @@ -13,10 +13,10 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { CompactType, DisplayGrid, - Gridster, + GridsterComponent, GridsterConfig, GridsterItem, - GridsterItemConfig, + GridsterItemComponent, GridType, } from 'angular-gridster2'; import { Angulartics2 } from 'angulartics2'; @@ -26,12 +26,9 @@ import { DashboardsService } from 'src/app/services/dashboards.service'; import { AlertComponent } from '../../ui-components/alert/alert.component'; import { WidgetDeleteDialogComponent } from '../widget-delete-dialog/widget-delete-dialog.component'; import { WidgetEditDialogComponent } from '../widget-edit-dialog/widget-edit-dialog.component'; -import { ChartWidgetComponent } from '../widget-renderers/chart-widget/chart-widget.component'; -import { CounterWidgetComponent } from '../widget-renderers/counter-widget/counter-widget.component'; -import { TableWidgetComponent } from '../widget-renderers/table-widget/table-widget.component'; -import { TextWidgetComponent } from '../widget-renderers/text-widget/text-widget.component'; +import { DashboardWidgetComponent } from '../widget-renderers/dashboard-widget/dashboard-widget.component'; -interface GridsterWidgetItem extends GridsterItemConfig { +interface GridsterWidgetItem extends GridsterItem { widget: DashboardWidget; } @@ -39,6 +36,7 @@ interface GridsterWidgetItem extends GridsterItemConfig { selector: 'app-dashboard-view', templateUrl: './dashboard-view.component.html', styleUrls: ['./dashboard-view.component.css'], + encapsulation: ViewEncapsulation.None, imports: [ CommonModule, RouterModule, @@ -48,13 +46,10 @@ interface GridsterWidgetItem extends GridsterItemConfig { MatTooltipModule, MatSlideToggleModule, MatProgressSpinnerModule, - Gridster, - GridsterItem, + GridsterComponent, + GridsterItemComponent, AlertComponent, - ChartWidgetComponent, - TableWidgetComponent, - CounterWidgetComponent, - TextWidgetComponent, + DashboardWidgetComponent, ], }) export class DashboardViewComponent implements OnInit { @@ -74,18 +69,8 @@ export class DashboardViewComponent implements OnInit { protected dashboard = computed(() => this._dashboards.dashboard()); protected loading = computed(() => this._dashboards.dashboardLoading()); - // Convert widgets to gridster items - protected gridsterItems = computed(() => { - const dashboard = this.dashboard(); - if (!dashboard?.widgets) return []; - return dashboard.widgets.map((widget) => ({ - x: widget.position_x, - y: widget.position_y, - cols: widget.width, - rows: widget.height, - widget: widget, - })); - }); + // Writable signal for gridster items (gridster needs mutable items) + protected gridsterItems = signal([]); protected gridsterOptions: GridsterConfig = { gridType: GridType.Fit, @@ -94,9 +79,22 @@ export class DashboardViewComponent implements OnInit { pushItems: true, draggable: { enabled: false, + ignoreContentClass: 'widget-content', + ignoreContent: true, + dragHandleClass: 'widget-header', }, resizable: { enabled: false, + handles: { + s: true, + e: true, + n: true, + w: true, + se: true, + ne: true, + sw: true, + nw: true, + }, }, minCols: 12, maxCols: 12, @@ -108,13 +106,30 @@ export class DashboardViewComponent implements OnInit { minItemRows: 2, maxItemCols: 12, maxItemRows: 12, - itemChangeCallback: (item: GridsterItemConfig, itemComponent: GridsterItem) => - this._onItemChange(item as GridsterWidgetItem), + itemChangeCallback: (item: GridsterItem) => this._onItemChange(item as GridsterWidgetItem), }; private connectionTitle = toSignal(this._connections.getCurrentConnectionTitle(), { initialValue: '' }); constructor() { + // Sync gridster items when dashboard changes + effect(() => { + const dashboard = this.dashboard(); + if (dashboard?.widgets) { + this.gridsterItems.set( + dashboard.widgets.map((widget) => ({ + x: widget.position_x, + y: widget.position_y, + cols: widget.width, + rows: widget.height, + widget: widget, + })), + ); + } else { + this.gridsterItems.set([]); + } + }); + // Connection title effect effect(() => { const dashboard = this.dashboard(); @@ -225,12 +240,19 @@ export class DashboardViewComponent implements OnInit { widget.width !== item.cols || widget.height !== item.rows ) { + // Update local widget state to prevent duplicate saves + widget.position_x = item.x ?? widget.position_x; + widget.position_y = item.y ?? widget.position_y; + widget.width = item.cols ?? widget.width; + widget.height = item.rows ?? widget.height; + + // Save to backend this._dashboards .updateWidgetPosition(this.connectionId(), this.dashboardId(), widget.id, { - position_x: item.x, - position_y: item.y, - width: item.cols, - height: item.rows, + position_x: widget.position_x, + position_y: widget.position_y, + width: widget.width, + height: widget.height, }) .subscribe(); } diff --git a/frontend/src/app/components/dashboards/widget-delete-dialog/widget-delete-dialog.component.html b/frontend/src/app/components/dashboards/widget-delete-dialog/widget-delete-dialog.component.html index 0ea46e552..8567c8525 100644 --- a/frontend/src/app/components/dashboards/widget-delete-dialog/widget-delete-dialog.component.html +++ b/frontend/src/app/components/dashboards/widget-delete-dialog/widget-delete-dialog.component.html @@ -4,7 +4,7 @@

Delete Widget

warning
-

Are you sure you want to delete the widget {{data.widget.name}}?

+

Are you sure you want to delete this widget?

This action cannot be undone. The widget will be permanently removed from the dashboard.

diff --git a/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.css b/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.css index 3e062464e..2e9f21fc6 100644 --- a/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.css +++ b/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.css @@ -2,7 +2,7 @@ display: flex; flex-direction: column; gap: 8px; - min-width: 500px; + min-width: 400px; } @media (width <= 600px) { @@ -14,8 +14,3 @@ .full-width { width: 100%; } - -.type-icon { - margin-right: 8px; - vertical-align: middle; -} diff --git a/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.html b/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.html index b3b28eb32..2cacb6888 100644 --- a/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.html +++ b/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.html @@ -1,43 +1,7 @@ -

{{ isEdit ? 'Edit Chart' : 'Add Chart' }}

+

{{ isEdit ? 'Edit Widget' : 'Add Widget' }}

- - Name - - @if (form.get('name')?.hasError('required')) { - Name is required - } - @if (form.get('name')?.hasError('maxlength')) { - Name must be less than 255 characters - } - - - - Description - - @if (form.get('description')?.hasError('maxlength')) { - Description must be less than 1000 characters - } - - - - Chart Type - - @for (type of chartTypes; track type.value) { - {{ type.label }} - } - - - Saved Query {{ isEdit ? 'Edit Chart' : 'Add Chart' }} {{ query.name }} } + Select a saved query to display in this widget @if (form.get('query_id')?.hasError('required')) { - Saved query is required + Please select a saved query } - Select a saved query to fetch data for this chart
diff --git a/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.spec.ts b/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.spec.ts index a8aefe1c9..17ff71576 100644 --- a/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.spec.ts +++ b/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.spec.ts @@ -5,24 +5,62 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { Angulartics2Module } from 'angulartics2'; +import { of } from 'rxjs'; +import { DashboardWidget } from 'src/app/models/dashboard'; import { SavedQuery } from 'src/app/models/saved-query'; import { DashboardsService } from 'src/app/services/dashboards.service'; import { SavedQueriesService } from 'src/app/services/saved-queries.service'; import { WidgetEditDialogComponent } from './widget-edit-dialog.component'; +type WidgetEditDialogTestable = WidgetEditDialogComponent & { + form: ReturnType; + isEdit: boolean; + onSubmit(): void; +}; + describe('WidgetEditDialogComponent', () => { let component: WidgetEditDialogComponent; + let testable: WidgetEditDialogTestable; let fixture: ComponentFixture; let mockDashboardsService: Partial; let mockSavedQueriesService: Partial; let savedQueriesSignal: WritableSignal; + let mockDialogRef: { close: ReturnType }; + + const mockSavedQueries: SavedQuery[] = [ + { + id: 'query-1', + name: 'Avg Clicks / Short URL', + description: null, + widget_type: 'chart', + chart_type: 'bar', + widget_options: null, + query_text: 'SELECT * FROM stats', + connection_id: 'test-conn', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + { + id: 'query-2', + name: 'Total Users', + description: 'Count of all users', + widget_type: 'counter', + chart_type: null, + widget_options: { value_column: 'count' }, + query_text: 'SELECT COUNT(*) as count FROM users', + connection_id: 'test-conn', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }, + ]; - beforeEach(async () => { - savedQueriesSignal = signal([]); + function setupTestBed(widget: DashboardWidget | null) { + savedQueriesSignal = signal(mockSavedQueries); + mockDialogRef = { close: vi.fn() }; mockDashboardsService = { - createWidget: vi.fn(), - updateWidget: vi.fn(), + createWidget: vi.fn().mockReturnValue(of({ id: 'new-widget' })), + updateWidget: vi.fn().mockReturnValue(of({ id: 'updated-widget' })), } as Partial; mockSavedQueriesService = { @@ -30,27 +68,138 @@ describe('WidgetEditDialogComponent', () => { setActiveConnection: vi.fn(), }; - await TestBed.configureTestingModule({ + return TestBed.configureTestingModule({ imports: [WidgetEditDialogComponent, BrowserAnimationsModule, Angulartics2Module.forRoot()], providers: [ provideHttpClient(), provideHttpClientTesting(), { provide: MAT_DIALOG_DATA, - useValue: { connectionId: 'test-conn', dashboardId: 'test-dash', widget: null }, + useValue: { connectionId: 'test-conn', dashboardId: 'test-dash', widget }, }, - { provide: MatDialogRef, useValue: { close: vi.fn() } }, + { provide: MatDialogRef, useValue: mockDialogRef }, { provide: DashboardsService, useValue: mockDashboardsService }, { provide: SavedQueriesService, useValue: mockSavedQueriesService }, ], }).compileComponents(); + } + + describe('Add widget mode', () => { + beforeEach(async () => { + await setupTestBed(null); + fixture = TestBed.createComponent(WidgetEditDialogComponent); + component = fixture.componentInstance; + testable = component as WidgetEditDialogTestable; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should be in add mode when widget is null', () => { + expect(testable.isEdit).toBe(false); + }); + + it('should require query_id when adding widget', () => { + const queryIdControl = testable.form.get('query_id'); + expect(queryIdControl?.hasError('required')).toBe(true); + }); + + it('should call setActiveConnection on init', () => { + expect(mockSavedQueriesService.setActiveConnection).toHaveBeenCalledWith('test-conn'); + }); + + it('should call createWidget with position data when form is submitted', () => { + testable.form.get('query_id')?.setValue('query-1'); + testable.onSubmit(); + + expect(mockDashboardsService.createWidget).toHaveBeenCalledWith('test-conn', 'test-dash', { + query_id: 'query-1', + position_x: 0, + position_y: 0, + width: 4, + height: 4, + }); + }); + + it('should close dialog after successful widget creation', () => { + testable.form.get('query_id')?.setValue('query-1'); + testable.onSubmit(); + + expect(mockDialogRef.close).toHaveBeenCalledWith(true); + }); + }); - fixture = TestBed.createComponent(WidgetEditDialogComponent); - component = fixture.componentInstance; - fixture.detectChanges(); + describe('Edit widget mode', () => { + const existingWidget: DashboardWidget = { + id: 'widget-123', + position_x: 2, + position_y: 3, + width: 4, + height: 4, + query_id: 'query-1', + dashboard_id: 'test-dash', + }; + + beforeEach(async () => { + await setupTestBed(existingWidget); + fixture = TestBed.createComponent(WidgetEditDialogComponent); + component = fixture.componentInstance; + testable = component as WidgetEditDialogTestable; + fixture.detectChanges(); + }); + + it('should be in edit mode when widget is provided', () => { + expect(testable.isEdit).toBe(true); + }); + + it('should initialize form with existing query_id', () => { + expect(testable.form.get('query_id')?.value).toBe('query-1'); + }); + + it('should call updateWidget when form is submitted', () => { + testable.form.get('query_id')?.setValue('query-2'); + testable.onSubmit(); + + expect(mockDashboardsService.updateWidget).toHaveBeenCalledWith('test-conn', 'test-dash', 'widget-123', { + query_id: 'query-2', + }); + }); + + it('should close dialog after successful widget update', () => { + testable.form.get('query_id')?.setValue('query-2'); + testable.onSubmit(); + + expect(mockDialogRef.close).toHaveBeenCalledWith(true); + }); }); - it('should create', () => { - expect(component).toBeTruthy(); + describe('Widget without linked query', () => { + const widgetWithoutQuery: DashboardWidget = { + id: 'widget-456', + position_x: 0, + position_y: 0, + width: 4, + height: 4, + query_id: null, + dashboard_id: 'test-dash', + }; + + beforeEach(async () => { + await setupTestBed(widgetWithoutQuery); + fixture = TestBed.createComponent(WidgetEditDialogComponent); + component = fixture.componentInstance; + testable = component as WidgetEditDialogTestable; + fixture.detectChanges(); + }); + + it('should initialize form with empty query_id', () => { + expect(testable.form.get('query_id')?.value).toBe(''); + }); + + it('should show validation error for empty query_id', () => { + expect(testable.form.get('query_id')?.hasError('required')).toBe(true); + }); }); }); diff --git a/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.ts b/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.ts index d5e0ebda0..0dccb6558 100644 --- a/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.ts +++ b/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.ts @@ -4,7 +4,6 @@ import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angula 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'; import { MatSelectModule } from '@angular/material/select'; import { Angulartics2 } from 'angulartics2'; import { DashboardWidget } from 'src/app/models/dashboard'; @@ -15,29 +14,13 @@ import { SavedQueriesService } from 'src/app/services/saved-queries.service'; selector: 'app-widget-edit-dialog', templateUrl: './widget-edit-dialog.component.html', styleUrls: ['./widget-edit-dialog.component.css'], - imports: [ - CommonModule, - ReactiveFormsModule, - MatDialogModule, - MatButtonModule, - MatFormFieldModule, - MatInputModule, - MatSelectModule, - ], + imports: [CommonModule, ReactiveFormsModule, MatDialogModule, MatButtonModule, MatFormFieldModule, MatSelectModule], }) export class WidgetEditDialogComponent implements OnInit { protected submitting = signal(false); protected form!: FormGroup; protected isEdit: boolean; - protected chartTypes = [ - { value: 'bar', label: 'Bar Chart' }, - { value: 'line', label: 'Line Chart' }, - { value: 'pie', label: 'Pie Chart' }, - { value: 'doughnut', label: 'Doughnut Chart' }, - { value: 'polarArea', label: 'Polar Area Chart' }, - ]; - protected savedQueries = computed(() => this._savedQueries.savedQueries()); constructor( @@ -56,10 +39,8 @@ export class WidgetEditDialogComponent implements OnInit { this._savedQueries.setActiveConnection(this.data.connectionId); const widget = this.data.widget; + this.form = this.fb.group({ - name: [widget?.name || '', [Validators.required, Validators.maxLength(255)]], - description: [widget?.description || '', [Validators.maxLength(1000)]], - chart_type: [widget?.chart_type || 'bar', [Validators.required]], query_id: [widget?.query_id || '', [Validators.required]], }); } @@ -70,28 +51,17 @@ export class WidgetEditDialogComponent implements OnInit { this.submitting.set(true); const formValue = this.form.value; - const payload: Record = { - name: formValue.name, - description: formValue.description || undefined, - widget_type: 'chart', - chart_type: formValue.chart_type, - query_id: formValue.query_id, - }; - - if (!this.isEdit) { - payload['position_x'] = 0; - payload['position_y'] = 0; - payload['width'] = 4; - payload['height'] = 4; - } - if (this.isEdit) { + const payload = { + query_id: formValue.query_id, + }; + this._dashboards - .updateWidget(this.data.connectionId, this.data.dashboardId, this.data.widget!.id, payload as any) + .updateWidget(this.data.connectionId, this.data.dashboardId, this.data.widget!.id, payload) .subscribe({ next: () => { this.angulartics2.eventTrack.next({ - action: 'Dashboards: chart updated successfully', + action: 'Dashboards: widget updated successfully', }); this.submitting.set(false); this.dialogRef.close(true); @@ -101,10 +71,18 @@ export class WidgetEditDialogComponent implements OnInit { }, }); } else { - this._dashboards.createWidget(this.data.connectionId, this.data.dashboardId, payload as any).subscribe({ + const payload = { + query_id: formValue.query_id, + position_x: 0, + position_y: 0, + width: 4, + height: 4, + }; + + this._dashboards.createWidget(this.data.connectionId, this.data.dashboardId, payload).subscribe({ next: () => { this.angulartics2.eventTrack.next({ - action: 'Dashboards: chart created successfully', + action: 'Dashboards: widget created successfully', }); this.submitting.set(false); this.dialogRef.close(true); diff --git a/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.html b/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.html index b70ac4f7c..9771cdb01 100644 --- a/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.html +++ b/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.html @@ -1,17 +1,4 @@ -@if (loading()) { -
- -
-} @else if (error()) { -
-

{{ error() }}

-
-} @else if (!widget.query_id) { -
-

No query linked to this chart widget.

-

Edit this widget to link a saved query.

-
-} @else if (chartData()) { +@if (chartData()) {
{ let component: ChartWidgetComponent; let fixture: ComponentFixture; - let mockSavedQueriesService: Partial; beforeEach(async () => { - mockSavedQueriesService = { - executeSavedQuery: vi.fn(), - } as Partial; - await TestBed.configureTestingModule({ imports: [ChartWidgetComponent, BrowserAnimationsModule], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: SavedQueriesService, useValue: mockSavedQueriesService }, - ], + providers: [provideHttpClient(), provideHttpClientTesting()], }).compileComponents(); fixture = TestBed.createComponent(ChartWidgetComponent); component = fixture.componentInstance; component.widget = { id: 'test-id', - name: 'Test Chart', - widget_type: 'chart', - chart_type: 'bar', - description: null, position_x: 0, position_y: 0, width: 4, height: 4, - widget_options: {}, - query_id: null, + query_id: 'test-query', dashboard_id: 'test-dashboard', }; component.connectionId = 'test-conn'; + component.preloadedQuery = { + id: 'test-query', + name: 'Test Query', + description: null, + widget_type: 'chart', + chart_type: 'bar', + widget_options: { label_column: 'name', value_column: 'count' }, + query_text: 'SELECT * FROM stats', + connection_id: 'test-conn', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + component.preloadedData = [ + { name: 'A', count: 10 }, + { name: 'B', count: 20 }, + ]; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should display chart when data is provided', () => { + const compiled = fixture.nativeElement; + expect(compiled.querySelector('.chart-container')).toBeTruthy(); + }); + + it('should compute chart data from preloaded data', () => { + const chartData = component['chartData'](); + expect(chartData).toBeTruthy(); + expect(chartData?.labels).toEqual(['A', 'B']); + expect(chartData?.datasets[0].data).toEqual([10, 20]); + }); }); diff --git a/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.ts b/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.ts index a19ffbef3..1a69a1aa1 100644 --- a/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.ts +++ b/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.ts @@ -1,10 +1,10 @@ import { CommonModule } from '@angular/common'; -import { Component, computed, effect, Input, inject, signal } from '@angular/core'; +import { Component, computed, Input, OnInit, signal } from '@angular/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ChartConfiguration, ChartData, ChartType as ChartJsType } from 'chart.js'; import { BaseChartDirective } from 'ng2-charts'; import { DashboardWidget } from 'src/app/models/dashboard'; -import { SavedQueriesService } from 'src/app/services/saved-queries.service'; +import { SavedQuery } from 'src/app/models/saved-query'; @Component({ selector: 'app-chart-widget', @@ -12,32 +12,40 @@ import { SavedQueriesService } from 'src/app/services/saved-queries.service'; styleUrls: ['./chart-widget.component.css'], imports: [CommonModule, BaseChartDirective, MatProgressSpinnerModule], }) -export class ChartWidgetComponent { +export class ChartWidgetComponent implements OnInit { @Input({ required: true }) widget!: DashboardWidget; @Input({ required: true }) connectionId!: string; + @Input() preloadedQuery: SavedQuery | null = null; + @Input() preloadedData: Record[] = []; - private _savedQueries = inject(SavedQueriesService); - - protected loading = signal(false); - protected error = signal(null); protected data = signal[]>([]); + protected savedQuery = signal(null); protected chartData = computed | null>(() => { const data = this.data(); - if (!data.length) return null; + const query = this.savedQuery(); + if (!data.length || !query) return null; - const labelColumn = (this.widget.widget_options?.['label_column'] as string) || this._getFirstColumn(data); - const valueColumn = (this.widget.widget_options?.['value_column'] as string) || this._getSecondColumn(data); + const labelColumn = this._getLabelColumn(query, data); + const valueColumn = this._getValueColumn(query, data); + const labelType = (query.widget_options?.['label_type'] as 'values' | 'datetime') || 'values'; if (!labelColumn || !valueColumn) return null; - const labels = data.map((row) => String(row[labelColumn] ?? '')); + const labels = data.map((row) => { + const val = row[labelColumn]; + if (labelType === 'datetime' && val) { + return this._formatDatetime(val); + } + return String(val ?? ''); + }); const values = data.map((row) => { const val = row[valueColumn]; return typeof val === 'number' ? val : parseFloat(String(val)) || 0; }); - const isPieType = ['pie', 'doughnut', 'polarArea'].includes(this.widget.chart_type || 'bar'); + const chartType = query.chart_type || 'bar'; + const isPieType = ['pie', 'doughnut', 'polarArea'].includes(chartType); if (isPieType) { return { @@ -61,7 +69,7 @@ export class ChartWidgetComponent { backgroundColor: this.colorPalette[0], borderColor: this.colorPalette[0].replace('0.8', '1'), borderWidth: 1, - fill: this.widget.chart_type === 'line', + fill: chartType === 'line', }, ], }; @@ -92,49 +100,51 @@ export class ChartWidgetComponent { 'rgba(139, 92, 246, 0.8)', ]; - constructor() { - effect(() => { - // This effect runs when widget input changes - if (this.widget?.query_id) { - this._loadData(); - } - }); + ngOnInit(): void { + if (this.preloadedQuery) { + this.savedQuery.set(this.preloadedQuery); + } + if (this.preloadedData.length > 0) { + this.data.set(this.preloadedData); + } } get mappedChartType(): ChartJsType { - return (this.widget.chart_type || 'bar') as ChartJsType; + return (this.savedQuery()?.chart_type || 'bar') as ChartJsType; } - private _loadData(): void { - if (!this.widget.query_id) { - this.error.set('No query linked to this widget'); - return; - } - - this.loading.set(true); - this.error.set(null); - - this._savedQueries.executeSavedQuery(this.connectionId, this.widget.query_id).subscribe({ - next: (result) => { - this.data.set(result.data); - this.loading.set(false); - }, - error: (err) => { - this.error.set(err?.error?.message || 'Failed to load data'); - this.loading.set(false); - }, - }); - } + private _getLabelColumn(query: SavedQuery, data: Record[]): string | null { + const labelCol = query.widget_options?.['label_column'] as string | undefined; + if (labelCol) return labelCol; - private _getFirstColumn(data: Record[]): string | null { if (!data.length) return null; - const keys = Object.keys(data[0]); - return keys[0] || null; + return Object.keys(data[0])[0] || null; } - private _getSecondColumn(data: Record[]): string | null { + private _getValueColumn(query: SavedQuery, data: Record[]): string | null { + const valueCol = query.widget_options?.['value_column'] as string | undefined; + if (valueCol) return valueCol; + if (!data.length) return null; const keys = Object.keys(data[0]); return keys[1] || keys[0] || null; } + + private _formatDatetime(value: unknown): string { + if (!value) return ''; + + try { + const date = new Date(value as string | number | Date); + if (isNaN(date.getTime())) { + return String(value); + } + return date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + } catch { + return String(value); + } + } } diff --git a/frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.html b/frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.html index 2cc6326b3..9572b5838 100644 --- a/frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.html +++ b/frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.html @@ -1,17 +1,4 @@ -@if (loading()) { -
- -
-} @else if (error()) { -
-

{{ error() }}

-
-} @else if (!widget.query_id) { -
-

No query linked to this counter widget.

-

Edit this widget to link a saved query.

-
-} @else if (counterValue() !== null) { +@if (counterValue() !== null) {
{{ counterValue() }} @if (label()) { diff --git a/frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.spec.ts b/frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.spec.ts index 970645f01..36807049c 100644 --- a/frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.spec.ts +++ b/frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.spec.ts @@ -2,48 +2,53 @@ import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { SavedQueriesService } from 'src/app/services/saved-queries.service'; import { CounterWidgetComponent } from './counter-widget.component'; describe('CounterWidgetComponent', () => { let component: CounterWidgetComponent; let fixture: ComponentFixture; - let mockSavedQueriesService: Partial; beforeEach(async () => { - mockSavedQueriesService = { - executeSavedQuery: vi.fn(), - } as Partial; - await TestBed.configureTestingModule({ imports: [CounterWidgetComponent, BrowserAnimationsModule], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: SavedQueriesService, useValue: mockSavedQueriesService }, - ], + providers: [provideHttpClient(), provideHttpClientTesting()], }).compileComponents(); fixture = TestBed.createComponent(CounterWidgetComponent); component = fixture.componentInstance; component.widget = { id: 'test-id', - name: 'Test Counter', - widget_type: 'counter', - description: null, position_x: 0, position_y: 0, width: 2, height: 2, - widget_options: {}, - query_id: null, + query_id: 'test-query', dashboard_id: 'test-dashboard', }; component.connectionId = 'test-conn'; + component.preloadedQuery = { + id: 'test-query', + name: 'Test Counter', + description: null, + widget_type: 'counter', + chart_type: null, + widget_options: { value_column: 'total' }, + query_text: 'SELECT COUNT(*) as total FROM users', + connection_id: 'test-conn', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + component.preloadedData = [{ total: 42 }]; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should display counter value from preloaded data', () => { + const compiled = fixture.nativeElement; + expect(compiled.querySelector('.counter-container')).toBeTruthy(); + expect(compiled.querySelector('.counter-value').textContent).toContain('42'); + }); }); diff --git a/frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.ts b/frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.ts index ba82ba3cd..e7107c03e 100644 --- a/frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.ts +++ b/frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.ts @@ -1,8 +1,8 @@ import { CommonModule } from '@angular/common'; -import { Component, computed, effect, Input, inject, signal } from '@angular/core'; +import { Component, computed, Input, OnInit, signal } from '@angular/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { DashboardWidget } from 'src/app/models/dashboard'; -import { SavedQueriesService } from 'src/app/services/saved-queries.service'; +import { SavedQuery } from 'src/app/models/saved-query'; @Component({ selector: 'app-counter-widget', @@ -10,22 +10,21 @@ import { SavedQueriesService } from 'src/app/services/saved-queries.service'; styleUrls: ['./counter-widget.component.css'], imports: [CommonModule, MatProgressSpinnerModule], }) -export class CounterWidgetComponent { +export class CounterWidgetComponent implements OnInit { @Input({ required: true }) widget!: DashboardWidget; @Input({ required: true }) connectionId!: string; + @Input() preloadedQuery: SavedQuery | null = null; + @Input() preloadedData: Record[] = []; - private _savedQueries = inject(SavedQueriesService); - - protected loading = signal(false); - protected error = signal(null); protected data = signal[]>([]); + protected savedQuery = signal(null); protected counterValue = computed(() => { const data = this.data(); - if (!data.length) return null; + const query = this.savedQuery(); + if (!data.length || !query) return null; - // Get the value column from widget options or use the first column - const valueColumn = (this.widget.widget_options?.['value_column'] as string) || Object.keys(data[0])[0]; + const valueColumn = this._getValueColumn(query, data); if (!valueColumn) return null; const value = data[0][valueColumn]; @@ -37,44 +36,33 @@ export class CounterWidgetComponent { protected label = computed(() => { const data = this.data(); - if (!data.length) return ''; + const query = this.savedQuery(); + if (!data.length || !query) return ''; - const labelColumn = this.widget.widget_options?.['label_column'] as string; + const labelColumn = query.widget_options?.['label_column'] as string | undefined; if (labelColumn && data[0][labelColumn]) { return String(data[0][labelColumn]); } - const valueColumn = (this.widget.widget_options?.['value_column'] as string) || Object.keys(data[0])[0]; + const valueColumn = this._getValueColumn(query, data); return valueColumn || ''; }); - constructor() { - effect(() => { - if (this.widget?.query_id) { - this._loadData(); - } - }); - } - - private _loadData(): void { - if (!this.widget.query_id) { - this.error.set('No query linked to this widget'); - return; + ngOnInit(): void { + if (this.preloadedQuery) { + this.savedQuery.set(this.preloadedQuery); } + if (this.preloadedData.length > 0) { + this.data.set(this.preloadedData); + } + } - this.loading.set(true); - this.error.set(null); + private _getValueColumn(query: SavedQuery, data: Record[]): string | null { + const valueCol = query.widget_options?.['value_column'] as string | undefined; + if (valueCol) return valueCol; - this._savedQueries.executeSavedQuery(this.connectionId, this.widget.query_id).subscribe({ - next: (result) => { - this.data.set(result.data); - this.loading.set(false); - }, - error: (err) => { - this.error.set(err?.error?.message || 'Failed to load data'); - this.loading.set(false); - }, - }); + if (!data.length) return null; + return Object.keys(data[0])[0] || null; } private _formatNumber(value: number): string { diff --git a/frontend/src/app/components/dashboards/widget-renderers/dashboard-widget/dashboard-widget.component.css b/frontend/src/app/components/dashboards/widget-renderers/dashboard-widget/dashboard-widget.component.css new file mode 100644 index 000000000..9c2bf3f4b --- /dev/null +++ b/frontend/src/app/components/dashboards/widget-renderers/dashboard-widget/dashboard-widget.component.css @@ -0,0 +1,23 @@ +:host { + display: block; + width: 100%; + height: 100%; +} + +.widget-loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; +} + +.widget-error { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--mdc-theme-error, #f44336); + font-size: 14px; + text-align: center; + padding: 16px; +} diff --git a/frontend/src/app/components/dashboards/widget-renderers/dashboard-widget/dashboard-widget.component.html b/frontend/src/app/components/dashboards/widget-renderers/dashboard-widget/dashboard-widget.component.html new file mode 100644 index 000000000..6046b88f8 --- /dev/null +++ b/frontend/src/app/components/dashboards/widget-renderers/dashboard-widget/dashboard-widget.component.html @@ -0,0 +1,47 @@ +@if (loading()) { +
+ +
+} @else if (error()) { +
+ {{ error() }} +
+} @else if (savedQuery()) { + @switch (savedQuery()!.widget_type) { + @case ('chart') { + + + } + @case ('table') { + + + } + @case ('counter') { + + + } + @case ('text') { + + + } + @default { +
+ Unknown widget type: {{ savedQuery()!.widget_type }} +
+ } + } +} diff --git a/frontend/src/app/components/dashboards/widget-renderers/dashboard-widget/dashboard-widget.component.ts b/frontend/src/app/components/dashboards/widget-renderers/dashboard-widget/dashboard-widget.component.ts new file mode 100644 index 000000000..9808f1f64 --- /dev/null +++ b/frontend/src/app/components/dashboards/widget-renderers/dashboard-widget/dashboard-widget.component.ts @@ -0,0 +1,75 @@ +import { CommonModule } from '@angular/common'; +import { Component, effect, Input, inject, signal } from '@angular/core'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { forkJoin } from 'rxjs'; +import { DashboardWidget } from 'src/app/models/dashboard'; +import { SavedQuery } from 'src/app/models/saved-query'; +import { SavedQueriesService } from 'src/app/services/saved-queries.service'; +import { ChartWidgetComponent } from '../chart-widget/chart-widget.component'; +import { CounterWidgetComponent } from '../counter-widget/counter-widget.component'; +import { TableWidgetComponent } from '../table-widget/table-widget.component'; +import { TextWidgetComponent } from '../text-widget/text-widget.component'; + +@Component({ + selector: 'app-dashboard-widget', + templateUrl: './dashboard-widget.component.html', + styleUrls: ['./dashboard-widget.component.css'], + imports: [ + CommonModule, + MatProgressSpinnerModule, + ChartWidgetComponent, + CounterWidgetComponent, + TableWidgetComponent, + TextWidgetComponent, + ], +}) +export class DashboardWidgetComponent { + @Input({ required: true }) widget!: DashboardWidget; + @Input({ required: true }) connectionId!: string; + + private _savedQueries = inject(SavedQueriesService); + + protected loading = signal(true); + protected error = signal(null); + protected queryData = signal[]>([]); + + // Public signal for parent to access widget name + public savedQuery = signal(null); + + constructor() { + effect(() => { + if (this.widget?.query_id) { + this._loadData(); + } else { + this.loading.set(false); + this.error.set('No query linked to this widget'); + } + }); + } + + private _loadData(): void { + if (!this.widget.query_id) { + this.loading.set(false); + this.error.set('No query linked to this widget'); + return; + } + + this.loading.set(true); + this.error.set(null); + + forkJoin({ + query: this._savedQueries.fetchSavedQuery(this.connectionId, this.widget.query_id), + result: this._savedQueries.executeSavedQuery(this.connectionId, this.widget.query_id), + }).subscribe({ + next: ({ query, result }) => { + this.savedQuery.set(query); + this.queryData.set(result.data); + this.loading.set(false); + }, + error: (err) => { + this.error.set(err?.error?.message || 'Failed to load data'); + this.loading.set(false); + }, + }); + } +} diff --git a/frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.html b/frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.html index ca742af84..b9eea58c7 100644 --- a/frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.html +++ b/frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.html @@ -1,17 +1,4 @@ -@if (loading()) { -
- -
-} @else if (error()) { -
-

{{ error() }}

-
-} @else if (!widget.query_id) { -
-

No query linked to this table widget.

-

Edit this widget to link a saved query.

-
-} @else if (data().length > 0) { +@if (data().length > 0) {
@for (column of columns(); track column) { diff --git a/frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.spec.ts b/frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.spec.ts index c05ec23d8..59f9626f3 100644 --- a/frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.spec.ts +++ b/frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.spec.ts @@ -2,48 +2,61 @@ import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { SavedQueriesService } from 'src/app/services/saved-queries.service'; import { TableWidgetComponent } from './table-widget.component'; describe('TableWidgetComponent', () => { let component: TableWidgetComponent; let fixture: ComponentFixture; - let mockSavedQueriesService: Partial; beforeEach(async () => { - mockSavedQueriesService = { - executeSavedQuery: vi.fn(), - } as Partial; - await TestBed.configureTestingModule({ imports: [TableWidgetComponent, BrowserAnimationsModule], - providers: [ - provideHttpClient(), - provideHttpClientTesting(), - { provide: SavedQueriesService, useValue: mockSavedQueriesService }, - ], + providers: [provideHttpClient(), provideHttpClientTesting()], }).compileComponents(); fixture = TestBed.createComponent(TableWidgetComponent); component = fixture.componentInstance; component.widget = { id: 'test-id', - name: 'Test Table', - widget_type: 'table', - description: null, position_x: 0, position_y: 0, width: 4, height: 4, - widget_options: {}, - query_id: null, + query_id: 'test-query', dashboard_id: 'test-dashboard', }; component.connectionId = 'test-conn'; + component.preloadedQuery = { + id: 'test-query', + name: 'Test Table', + description: null, + widget_type: 'table', + chart_type: null, + widget_options: null, + query_text: 'SELECT * FROM users', + connection_id: 'test-conn', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + component.preloadedData = [ + { id: 1, name: 'John', email: 'john@example.com' }, + { id: 2, name: 'Jane', email: 'jane@example.com' }, + ]; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should display table with preloaded data', () => { + const compiled = fixture.nativeElement; + expect(compiled.querySelector('.table-container')).toBeTruthy(); + expect(compiled.querySelector('table')).toBeTruthy(); + }); + + it('should compute columns from data', () => { + const columns = component['columns'](); + expect(columns).toEqual(['id', 'name', 'email']); + }); }); diff --git a/frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.ts b/frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.ts index 6c0cbaa48..77e624c66 100644 --- a/frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.ts +++ b/frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.ts @@ -1,9 +1,9 @@ import { CommonModule } from '@angular/common'; -import { Component, computed, effect, Input, inject, signal } from '@angular/core'; +import { Component, computed, Input, OnInit, signal } from '@angular/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTableModule } from '@angular/material/table'; import { DashboardWidget } from 'src/app/models/dashboard'; -import { SavedQueriesService } from 'src/app/services/saved-queries.service'; +import { SavedQuery } from 'src/app/models/saved-query'; @Component({ selector: 'app-table-widget', @@ -11,14 +11,12 @@ import { SavedQueriesService } from 'src/app/services/saved-queries.service'; styleUrls: ['./table-widget.component.css'], imports: [CommonModule, MatTableModule, MatProgressSpinnerModule], }) -export class TableWidgetComponent { +export class TableWidgetComponent implements OnInit { @Input({ required: true }) widget!: DashboardWidget; @Input({ required: true }) connectionId!: string; + @Input() preloadedQuery: SavedQuery | null = null; + @Input() preloadedData: Record[] = []; - private _savedQueries = inject(SavedQueriesService); - - protected loading = signal(false); - protected error = signal(null); protected data = signal[]>([]); protected columns = computed(() => { @@ -27,32 +25,9 @@ export class TableWidgetComponent { return Object.keys(data[0]); }); - constructor() { - effect(() => { - if (this.widget?.query_id) { - this._loadData(); - } - }); - } - - private _loadData(): void { - if (!this.widget.query_id) { - this.error.set('No query linked to this widget'); - return; + ngOnInit(): void { + if (this.preloadedData.length > 0) { + this.data.set(this.preloadedData); } - - this.loading.set(true); - this.error.set(null); - - this._savedQueries.executeSavedQuery(this.connectionId, this.widget.query_id).subscribe({ - next: (result) => { - this.data.set(result.data); - this.loading.set(false); - }, - error: (err) => { - this.error.set(err?.error?.message || 'Failed to load data'); - this.loading.set(false); - }, - }); } } diff --git a/frontend/src/app/components/dashboards/widget-renderers/text-widget/text-widget.component.spec.ts b/frontend/src/app/components/dashboards/widget-renderers/text-widget/text-widget.component.spec.ts index 361d78375..b25658f12 100644 --- a/frontend/src/app/components/dashboards/widget-renderers/text-widget/text-widget.component.spec.ts +++ b/frontend/src/app/components/dashboards/widget-renderers/text-widget/text-widget.component.spec.ts @@ -16,21 +16,34 @@ describe('TextWidgetComponent', () => { component = fixture.componentInstance; component.widget = { id: 'test-id', - name: 'Test Text', - widget_type: 'text', - description: null, position_x: 0, position_y: 0, width: 4, height: 4, - widget_options: { text_content: '# Hello World' }, query_id: null, dashboard_id: 'test-dashboard', }; + component.preloadedQuery = { + id: 'test-query', + name: 'Test Text', + description: null, + widget_type: 'text', + chart_type: null, + widget_options: { text_content: '# Hello World' }, + query_text: '', + connection_id: 'test-conn', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should compute text content from preloaded query', () => { + const textContent = component['textContent'](); + expect(textContent).toBe('# Hello World'); + }); }); diff --git a/frontend/src/app/components/dashboards/widget-renderers/text-widget/text-widget.component.ts b/frontend/src/app/components/dashboards/widget-renderers/text-widget/text-widget.component.ts index 302938724..d0e1cb1a9 100644 --- a/frontend/src/app/components/dashboards/widget-renderers/text-widget/text-widget.component.ts +++ b/frontend/src/app/components/dashboards/widget-renderers/text-widget/text-widget.component.ts @@ -1,7 +1,8 @@ import { CommonModule } from '@angular/common'; -import { Component, computed, Input } from '@angular/core'; +import { Component, computed, Input, signal } from '@angular/core'; import { MarkdownModule } from 'ngx-markdown'; import { DashboardWidget } from 'src/app/models/dashboard'; +import { SavedQuery } from 'src/app/models/saved-query'; @Component({ selector: 'app-text-widget', @@ -11,8 +12,11 @@ import { DashboardWidget } from 'src/app/models/dashboard'; }) export class TextWidgetComponent { @Input({ required: true }) widget!: DashboardWidget; + @Input() preloadedQuery: SavedQuery | null = null; protected textContent = computed(() => { - return (this.widget.widget_options?.['text_content'] as string) || ''; + const query = this.preloadedQuery; + if (!query) return ''; + return (query.widget_options?.['text_content'] as string) || ''; }); } diff --git a/frontend/src/app/models/dashboard.ts b/frontend/src/app/models/dashboard.ts index c51d218ca..61a47242d 100644 --- a/frontend/src/app/models/dashboard.ts +++ b/frontend/src/app/models/dashboard.ts @@ -1,5 +1,3 @@ -export type DashboardWidgetType = 'table' | 'chart' | 'counter' | 'text'; - export interface Dashboard { id: string; name: string; @@ -12,15 +10,10 @@ export interface Dashboard { export interface DashboardWidget { id: string; - widget_type: DashboardWidgetType; - chart_type?: string; - name: string; - description: string | null; position_x: number; position_y: number; width: number; height: number; - widget_options: Record; query_id: string | null; dashboard_id: string; } @@ -36,27 +29,17 @@ export interface UpdateDashboardPayload { } export interface CreateWidgetPayload { - widget_type: DashboardWidgetType; - chart_type?: string; - name: string; - description?: string; position_x: number; position_y: number; width: number; height: number; - widget_options?: Record; - query_id?: string; + query_id: string; } export interface UpdateWidgetPayload { - widget_type?: DashboardWidgetType; - chart_type?: string; - name?: string; - description?: string; position_x?: number; position_y?: number; width?: number; height?: number; - widget_options?: Record; query_id?: string; } diff --git a/frontend/src/app/models/saved-query.ts b/frontend/src/app/models/saved-query.ts index 12aef8d94..910d396d6 100644 --- a/frontend/src/app/models/saved-query.ts +++ b/frontend/src/app/models/saved-query.ts @@ -1,7 +1,14 @@ +export type DashboardWidgetType = 'table' | 'chart' | 'counter' | 'text'; + +export type ChartType = 'bar' | 'line' | 'pie' | 'doughnut' | 'polarArea'; + export interface SavedQuery { id: string; name: string; description: string | null; + widget_type: DashboardWidgetType; + chart_type: ChartType | null; + widget_options: Record | null; query_text: string; connection_id: string; created_at: string; @@ -11,12 +18,18 @@ export interface SavedQuery { export interface CreateSavedQueryPayload { name: string; description?: string; + widget_type?: DashboardWidgetType; + chart_type?: ChartType; + widget_options?: Record; query_text: string; } export interface UpdateSavedQueryPayload { name?: string; description?: string; + widget_type?: DashboardWidgetType; + chart_type?: ChartType; + widget_options?: Record; query_text?: string; } @@ -36,5 +49,3 @@ export interface TestQueryResult { data: Record[]; execution_time_ms: number; } - -export type ChartType = 'bar' | 'line' | 'pie' | 'doughnut' | 'polarArea'; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 89e63b7ba..de39dd1d6 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -5868,16 +5868,16 @@ __metadata: languageName: node linkType: hard -"angular-gridster2@npm:^21.0.1": - version: 21.0.1 - resolution: "angular-gridster2@npm:21.0.1" +"angular-gridster2@npm:^20.0.0": + version: 20.2.4 + resolution: "angular-gridster2@npm:20.2.4" dependencies: tslib: ^2.4.0 peerDependencies: - "@angular/common": ^21.0.0 - "@angular/core": ^21.0.0 + "@angular/common": ^20.0.0 + "@angular/core": ^20.0.0 rxjs: ^7.0.0 - checksum: e40d09a1c55dc25f05078d25b060d4c7b221993d5bbf7df7c799bb837eec749f6333a40ac8c74b0fad1dad3d282089d50bfdefb92e500c607f8916a2bff8f422 + checksum: 2c62a5c9ff1cbf7930f4a886fc0eb769f2f6fc5e41dd75778e7a6873c4510c4e0ec1c562b00da9c3dccd37291c3c0b2e21aab2c1901c04514c68b25cf0ad4fb9 languageName: node linkType: hard @@ -11725,7 +11725,7 @@ __metadata: "@zxcvbn-ts/core": ^3.0.4 "@zxcvbn-ts/language-en": ^3.0.2 amplitude-js: ^8.21.9 - angular-gridster2: ^21.0.1 + angular-gridster2: ^20.0.0 angular-password-strength-meter: "npm:@eresearchqut/angular-password-strength-meter@^13.0.7" angulartics2: ^14.1.0 chart.js: ^4.5.1 From 9f74edad98fe6ba8ff0d51ec8d18a8f87f161fa5 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Wed, 4 Feb 2026 08:35:45 +0000 Subject: [PATCH 3/5] feat: improve chart configuration UI and add time-based axis support - Separate chart configuration from preview section in chart-edit - Add "Configure Chart" link to dashboard widget menu - Rename "Edit" to "Change Query" for clarity - Add time-based X axis for datetime label type with gap support - Install chartjs-adapter-date-fns for Chart.js time scale Co-Authored-By: Claude Opus 4.5 --- frontend/package.json | 1 + .../chart-edit/chart-edit.component.css | 7 + .../chart-edit/chart-edit.component.html | 120 ++++----- .../chart-preview/chart-preview.component.ts | 162 ++++++++---- .../dashboard-delete-dialog.component.ts | 22 +- .../dashboard-edit-dialog.component.ts | 41 ++-- .../dashboard-view.component.html | 8 +- .../dashboard-view.component.ts | 68 +++--- .../dashboards-list.component.ts | 14 +- .../widget-delete-dialog.component.ts | 26 +- .../widget-edit-dialog.component.spec.ts | 15 +- .../widget-edit-dialog.component.ts | 46 ++-- .../chart-widget/chart-widget.component.html | 2 +- .../chart-widget/chart-widget.component.ts | 174 +++++++++---- .../dashboard-widget.component.ts | 32 +-- .../src/app/services/dashboards.service.ts | 230 ++++++++++-------- frontend/yarn.lock | 11 + 17 files changed, 588 insertions(+), 391 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 4ffd6a5ac..6b87b1ced 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -40,6 +40,7 @@ "angular-password-strength-meter": "npm:@eresearchqut/angular-password-strength-meter@^13.0.7", "angulartics2": "^14.1.0", "chart.js": "^4.5.1", + "chartjs-adapter-date-fns": "^3.0.0", "color-string": "^2.0.1", "convert": "^5.12.0", "date-fns": "^4.1.0", diff --git a/frontend/src/app/components/charts/chart-edit/chart-edit.component.css b/frontend/src/app/components/charts/chart-edit/chart-edit.component.css index 542475ae6..d776500ba 100644 --- a/frontend/src/app/components/charts/chart-edit/chart-edit.component.css +++ b/frontend/src/app/components/charts/chart-edit/chart-edit.component.css @@ -72,6 +72,13 @@ } .editor-section, +.right-panel { + display: flex; + flex-direction: column; + gap: 24px; +} + +.config-section, .preview-section { display: flex; flex-direction: column; diff --git a/frontend/src/app/components/charts/chart-edit/chart-edit.component.html b/frontend/src/app/components/charts/chart-edit/chart-edit.component.html index 0d4f1c502..a8005ae4b 100644 --- a/frontend/src/app/components/charts/chart-edit/chart-edit.component.html +++ b/frontend/src/app/components/charts/chart-edit/chart-edit.component.html @@ -57,64 +57,72 @@

SQL Query

-
-
-

Chart Preview

- - Executed in {{ executionTime() }}ms - -
- -
- - Chart Type - - - {{ type.label }} - - - - - - Label Column - - - {{ col }} - - - - - - Label Type - - - {{ type.label }} - - - - - - Value Column - - - {{ col }} - - - -
- -
- - +
+
+
+

Chart Configuration

+ + Executed in {{ executionTime() }}ms + +
+ +
+ + Chart Type + + + {{ type.label }} + + + + + + Label Column + + + {{ col }} + + + + + + Label Type + + + {{ type.label }} + + + + + + Value Column + + + {{ col }} + + + +
-
-

Select label and value columns to display the chart

+
+
+

Chart Preview

+
+ +
+ + +
+ +
+

Select label and value columns to display the chart

+
diff --git a/frontend/src/app/components/charts/chart-preview/chart-preview.component.ts b/frontend/src/app/components/charts/chart-preview/chart-preview.component.ts index 9e6ead823..f7d6e3443 100644 --- a/frontend/src/app/components/charts/chart-preview/chart-preview.component.ts +++ b/frontend/src/app/components/charts/chart-preview/chart-preview.component.ts @@ -1,6 +1,7 @@ import { CommonModule } from '@angular/common'; import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; import { ChartConfiguration, ChartData, ChartType as ChartJsType } from 'chart.js'; +import 'chartjs-adapter-date-fns'; import { BaseChartDirective } from 'ng2-charts'; import { ChartType } from 'src/app/models/saved-query'; @@ -18,16 +19,7 @@ export class ChartPreviewComponent implements OnChanges { @Input() labelType: 'values' | 'datetime' = 'values'; public chartData: ChartData | null = null; - public chartOptions: ChartConfiguration['options'] = { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - display: true, - position: 'top', - }, - }, - }; + public chartOptions: ChartConfiguration['options'] = this._getDefaultOptions(); private colorPalette = [ 'rgba(99, 102, 241, 0.8)', @@ -58,71 +50,147 @@ export class ChartPreviewComponent implements OnChanges { return this.chartType as ChartJsType; } + private _getDefaultOptions(): ChartConfiguration['options'] { + return { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: true, + position: 'top', + }, + }, + }; + } + + private _getTimeScaleOptions(): ChartConfiguration['options'] { + return { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: true, + position: 'top', + }, + }, + scales: { + x: { + type: 'time', + time: { + tooltipFormat: 'MMM d, yyyy', + displayFormats: { + day: 'MMM d', + week: 'MMM d', + month: 'MMM yyyy', + year: 'yyyy', + }, + }, + title: { + display: true, + text: this.labelColumn, + }, + }, + y: { + beginAtZero: true, + title: { + display: true, + text: this.valueColumn, + }, + }, + }, + }; + } + private updateChartData(): void { if (!this.data.length || !this.labelColumn || !this.valueColumn) { this.chartData = null; return; } - const labels = this.data.map((row) => { - const val = row[this.labelColumn]; - if (this.labelType === 'datetime' && val) { - return this.formatDatetime(val); - } - return String(val ?? ''); - }); - const values = this.data.map((row) => { - const val = row[this.valueColumn]; - return typeof val === 'number' ? val : parseFloat(String(val)) || 0; - }); - const isPieType = ['pie', 'doughnut', 'polarArea'].includes(this.chartType); + const useTimeScale = this.labelType === 'datetime' && !isPieType; + + // Update options based on whether we're using time scale + this.chartOptions = useTimeScale ? this._getTimeScaleOptions() : this._getDefaultOptions(); + + if (useTimeScale) { + // For time scale, use {x, y} data points + const dataPoints = this.data + .map((row) => { + const dateVal = row[this.labelColumn]; + const numVal = row[this.valueColumn]; + const date = this._parseDate(dateVal); + if (!date) return null; + return { + x: date.getTime(), + y: typeof numVal === 'number' ? numVal : parseFloat(String(numVal)) || 0, + }; + }) + .filter((point): point is { x: number; y: number } => point !== null) + .sort((a, b) => a.x - b.x); - if (isPieType) { - this.chartData = { - labels, - datasets: [ - { - data: values, - backgroundColor: this.colorPalette.slice(0, values.length), - borderColor: this.colorPalette.slice(0, values.length).map((c) => c.replace('0.8', '1')), - borderWidth: 1, - }, - ], - }; - } else { this.chartData = { - labels, datasets: [ { label: this.valueColumn, - data: values, + data: dataPoints, backgroundColor: this.colorPalette[0], borderColor: this.colorPalette[0].replace('0.8', '1'), borderWidth: 1, fill: this.chartType === 'line', + spanGaps: false, }, ], }; + } else { + // For categorical scale, use labels + values + const labels = this.data.map((row) => String(row[this.labelColumn] ?? '')); + const values = this.data.map((row) => { + const val = row[this.valueColumn]; + return typeof val === 'number' ? val : parseFloat(String(val)) || 0; + }); + + if (isPieType) { + this.chartData = { + labels, + datasets: [ + { + data: values, + backgroundColor: this.colorPalette.slice(0, values.length), + borderColor: this.colorPalette.slice(0, values.length).map((c) => c.replace('0.8', '1')), + borderWidth: 1, + }, + ], + }; + } else { + this.chartData = { + labels, + datasets: [ + { + label: this.valueColumn, + data: values, + backgroundColor: this.colorPalette[0], + borderColor: this.colorPalette[0].replace('0.8', '1'), + borderWidth: 1, + fill: this.chartType === 'line', + }, + ], + }; + } } } - private formatDatetime(value: unknown): string { - if (!value) return ''; + private _parseDate(value: unknown): Date | null { + if (!value) return null; try { const date = new Date(value as string | number | Date); if (isNaN(date.getTime())) { - return String(value); + return null; } - // Format as localized date string - return date.toLocaleDateString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric', - }); + return date; } catch { - return String(value); + return null; } } } diff --git a/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.ts b/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.ts index 9b67f4553..fef945f50 100644 --- a/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.ts +++ b/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.ts @@ -23,19 +23,15 @@ export class DashboardDeleteDialogComponent { private angulartics2: Angulartics2, ) {} - onDelete(): void { + async onDelete(): Promise { this.submitting.set(true); - this._dashboards.deleteDashboard(this.data.connectionId, this.data.dashboard.id).subscribe({ - next: () => { - this.angulartics2.eventTrack.next({ - action: 'Dashboards: dashboard deleted successfully', - }); - this.submitting.set(false); - this.dialogRef.close(true); - }, - error: () => { - this.submitting.set(false); - }, - }); + const result = await this._dashboards.deleteDashboard(this.data.connectionId, this.data.dashboard.id); + if (result) { + this.angulartics2.eventTrack.next({ + action: 'Dashboards: dashboard deleted successfully', + }); + this.dialogRef.close(true); + } + this.submitting.set(false); } } diff --git a/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.ts b/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.ts index 8abd1492c..6ccd14a0c 100644 --- a/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.ts +++ b/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.ts @@ -46,38 +46,29 @@ export class DashboardEditDialogComponent implements OnInit { }); } - onSubmit(): void { + async onSubmit(): Promise { if (this.form.invalid) return; this.submitting.set(true); const payload = this.form.value; if (this.isEdit) { - this._dashboards.updateDashboard(this.data.connectionId, this.data.dashboard!.id, payload).subscribe({ - next: () => { - this.angulartics2.eventTrack.next({ - action: 'Dashboards: dashboard updated successfully', - }); - this.submitting.set(false); - this.dialogRef.close(true); - }, - error: () => { - this.submitting.set(false); - }, - }); + const result = await this._dashboards.updateDashboard(this.data.connectionId, this.data.dashboard!.id, payload); + if (result) { + this.angulartics2.eventTrack.next({ + action: 'Dashboards: dashboard updated successfully', + }); + this.dialogRef.close(true); + } } else { - this._dashboards.createDashboard(this.data.connectionId, payload).subscribe({ - next: () => { - this.angulartics2.eventTrack.next({ - action: 'Dashboards: dashboard created successfully', - }); - this.submitting.set(false); - this.dialogRef.close(true); - }, - error: () => { - this.submitting.set(false); - }, - }); + const result = await this._dashboards.createDashboard(this.data.connectionId, payload); + if (result) { + this.angulartics2.eventTrack.next({ + action: 'Dashboards: dashboard created successfully', + }); + this.dialogRef.close(true); + } } + this.submitting.set(false); } } diff --git a/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.html b/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.html index 5a13edf78..0496d6e8d 100644 --- a/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.html +++ b/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.html @@ -58,9 +58,13 @@

No charts yet

more_vert + + tune + Configure Chart +
} @else { diff --git a/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.ts b/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.ts index 1a69a1aa1..53f267340 100644 --- a/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.ts +++ b/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.ts @@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common'; import { Component, computed, Input, OnInit, signal } from '@angular/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ChartConfiguration, ChartData, ChartType as ChartJsType } from 'chart.js'; +import 'chartjs-adapter-date-fns'; import { BaseChartDirective } from 'ng2-charts'; import { DashboardWidget } from 'src/app/models/dashboard'; import { SavedQuery } from 'src/app/models/saved-query'; @@ -32,60 +33,94 @@ export class ChartWidgetComponent implements OnInit { if (!labelColumn || !valueColumn) return null; - const labels = data.map((row) => { - const val = row[labelColumn]; - if (labelType === 'datetime' && val) { - return this._formatDatetime(val); - } - return String(val ?? ''); - }); - const values = data.map((row) => { - const val = row[valueColumn]; - return typeof val === 'number' ? val : parseFloat(String(val)) || 0; - }); - const chartType = query.chart_type || 'bar'; const isPieType = ['pie', 'doughnut', 'polarArea'].includes(chartType); + const useTimeScale = labelType === 'datetime' && !isPieType; + + if (useTimeScale) { + // For time scale, use {x, y} data points + const dataPoints = data + .map((row) => { + const dateVal = row[labelColumn]; + const numVal = row[valueColumn]; + const date = this._parseDate(dateVal); + if (!date) return null; + return { + x: date.getTime(), + y: typeof numVal === 'number' ? numVal : parseFloat(String(numVal)) || 0, + }; + }) + .filter((point): point is { x: number; y: number } => point !== null) + .sort((a, b) => a.x - b.x); - if (isPieType) { - return { - labels, - datasets: [ - { - data: values, - backgroundColor: this.colorPalette.slice(0, values.length), - borderColor: this.colorPalette.slice(0, values.length).map((c) => c.replace('0.8', '1')), - borderWidth: 1, - }, - ], - }; - } else { return { - labels, datasets: [ { label: valueColumn, - data: values, + data: dataPoints, backgroundColor: this.colorPalette[0], borderColor: this.colorPalette[0].replace('0.8', '1'), borderWidth: 1, fill: chartType === 'line', + spanGaps: false, }, ], }; + } else { + // For categorical scale, use labels + values + const labels = data.map((row) => String(row[labelColumn] ?? '')); + const values = data.map((row) => { + const val = row[valueColumn]; + return typeof val === 'number' ? val : parseFloat(String(val)) || 0; + }); + + if (isPieType) { + return { + labels, + datasets: [ + { + data: values, + backgroundColor: this.colorPalette.slice(0, values.length), + borderColor: this.colorPalette.slice(0, values.length).map((c) => c.replace('0.8', '1')), + borderWidth: 1, + }, + ], + }; + } else { + return { + labels, + datasets: [ + { + label: valueColumn, + data: values, + backgroundColor: this.colorPalette[0], + borderColor: this.colorPalette[0].replace('0.8', '1'), + borderWidth: 1, + fill: chartType === 'line', + }, + ], + }; + } } }); - protected chartOptions: ChartConfiguration['options'] = { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - display: true, - position: 'top', - }, - }, - }; + protected chartOptions = computed(() => { + const query = this.savedQuery(); + const data = this.data(); + if (!query || !data.length) return this._getDefaultOptions(); + + const labelColumn = this._getLabelColumn(query, data); + const valueColumn = this._getValueColumn(query, data); + const labelType = (query.widget_options?.['label_type'] as 'values' | 'datetime') || 'values'; + const chartType = query.chart_type || 'bar'; + const isPieType = ['pie', 'doughnut', 'polarArea'].includes(chartType); + const useTimeScale = labelType === 'datetime' && !isPieType; + + if (useTimeScale) { + return this._getTimeScaleOptions(labelColumn || '', valueColumn || ''); + } + return this._getDefaultOptions(); + }); private colorPalette = [ 'rgba(99, 102, 241, 0.8)', @@ -130,21 +165,68 @@ export class ChartWidgetComponent implements OnInit { return keys[1] || keys[0] || null; } - private _formatDatetime(value: unknown): string { - if (!value) return ''; + private _parseDate(value: unknown): Date | null { + if (!value) return null; try { const date = new Date(value as string | number | Date); if (isNaN(date.getTime())) { - return String(value); + return null; } - return date.toLocaleDateString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric', - }); + return date; } catch { - return String(value); + return null; } } + + private _getDefaultOptions(): ChartConfiguration['options'] { + return { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: true, + position: 'top', + }, + }, + }; + } + + private _getTimeScaleOptions(labelColumn: string, valueColumn: string): ChartConfiguration['options'] { + return { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: true, + position: 'top', + }, + }, + scales: { + x: { + type: 'time', + time: { + tooltipFormat: 'MMM d, yyyy', + displayFormats: { + day: 'MMM d', + week: 'MMM d', + month: 'MMM yyyy', + year: 'yyyy', + }, + }, + title: { + display: true, + text: labelColumn, + }, + }, + y: { + beginAtZero: true, + title: { + display: true, + text: valueColumn, + }, + }, + }, + }; + } } diff --git a/frontend/src/app/components/dashboards/widget-renderers/dashboard-widget/dashboard-widget.component.ts b/frontend/src/app/components/dashboards/widget-renderers/dashboard-widget/dashboard-widget.component.ts index 9808f1f64..b41b6a23c 100644 --- a/frontend/src/app/components/dashboards/widget-renderers/dashboard-widget/dashboard-widget.component.ts +++ b/frontend/src/app/components/dashboards/widget-renderers/dashboard-widget/dashboard-widget.component.ts @@ -1,7 +1,7 @@ import { CommonModule } from '@angular/common'; import { Component, effect, Input, inject, signal } from '@angular/core'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { forkJoin } from 'rxjs'; +import { firstValueFrom } from 'rxjs'; import { DashboardWidget } from 'src/app/models/dashboard'; import { SavedQuery } from 'src/app/models/saved-query'; import { SavedQueriesService } from 'src/app/services/saved-queries.service'; @@ -47,7 +47,7 @@ export class DashboardWidgetComponent { }); } - private _loadData(): void { + private async _loadData(): Promise { if (!this.widget.query_id) { this.loading.set(false); this.error.set('No query linked to this widget'); @@ -57,19 +57,19 @@ export class DashboardWidgetComponent { this.loading.set(true); this.error.set(null); - forkJoin({ - query: this._savedQueries.fetchSavedQuery(this.connectionId, this.widget.query_id), - result: this._savedQueries.executeSavedQuery(this.connectionId, this.widget.query_id), - }).subscribe({ - next: ({ query, result }) => { - this.savedQuery.set(query); - this.queryData.set(result.data); - this.loading.set(false); - }, - error: (err) => { - this.error.set(err?.error?.message || 'Failed to load data'); - this.loading.set(false); - }, - }); + try { + const [query, result] = await Promise.all([ + firstValueFrom(this._savedQueries.fetchSavedQuery(this.connectionId, this.widget.query_id)), + firstValueFrom(this._savedQueries.executeSavedQuery(this.connectionId, this.widget.query_id)), + ]); + + this.savedQuery.set(query); + this.queryData.set(result.data); + this.loading.set(false); + } catch (err: unknown) { + const error = err as { error?: { message?: string } }; + this.error.set(error?.error?.message || 'Failed to load data'); + this.loading.set(false); + } } } diff --git a/frontend/src/app/services/dashboards.service.ts b/frontend/src/app/services/dashboards.service.ts index 206fb21f5..74a9e3580 100644 --- a/frontend/src/app/services/dashboards.service.ts +++ b/frontend/src/app/services/dashboards.service.ts @@ -1,8 +1,6 @@ import { HttpClient } from '@angular/common/http'; -import { computed, Injectable, inject, signal } from '@angular/core'; -import { rxResource } from '@angular/core/rxjs-interop'; -import { EMPTY, Observable } from 'rxjs'; -import { catchError, tap } from 'rxjs/operators'; +import { computed, Injectable, inject, ResourceRef, resource, signal } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; import { CreateDashboardPayload, CreateWidgetPayload, @@ -31,33 +29,37 @@ export class DashboardsService { // Active dashboard for reactive fetching of single dashboard private _activeDashboardId = signal(null); - // Resource for dashboards list - private _dashboardsResource = rxResource({ + // Resource for dashboards list (using pure signal-based resource with HttpClient) + private _dashboardsResource: ResourceRef = resource({ params: () => this._activeConnectionId(), - stream: ({ params: connectionId }) => { - if (!connectionId) return EMPTY; - return this._http.get(`/dashboards/${connectionId}`).pipe( - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || 'Failed to fetch dashboards'); - return EMPTY; - }), - ); + loader: async ({ params: connectionId }) => { + if (!connectionId) return []; + try { + return await firstValueFrom(this._http.get(`/dashboards/${connectionId}`)); + } catch (err) { + console.log(err); + const error = err as { error?: { message?: string } }; + this._notifications.showErrorSnackbar(error?.error?.message || 'Failed to fetch dashboards'); + return []; + } }, }); // Resource for single dashboard with widgets - private _dashboardResource = rxResource({ + private _dashboardResource: ResourceRef = resource({ params: () => ({ connectionId: this._activeConnectionId(), dashboardId: this._activeDashboardId() }), - stream: ({ params }) => { - if (!params.connectionId || !params.dashboardId) return EMPTY; - return this._http.get(`/dashboard/${params.dashboardId}/${params.connectionId}`).pipe( - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || 'Failed to fetch dashboard'); - return EMPTY; - }), - ); + loader: async ({ params }) => { + if (!params.connectionId || !params.dashboardId) return null; + try { + return await firstValueFrom( + this._http.get(`/dashboard/${params.dashboardId}/${params.connectionId}`), + ); + } catch (err) { + console.log(err); + const error = err as { error?: { message?: string } }; + this._notifications.showErrorSnackbar(error?.error?.message || 'Failed to fetch dashboard'); + return null; + } }, }); @@ -87,107 +89,125 @@ export class DashboardsService { this._dashboardResource.reload(); } - // Dashboard CRUD operations - createDashboard(connectionId: string, payload: CreateDashboardPayload): Observable { - return this._http.post(`/dashboards/${connectionId}`, payload).pipe( - tap(() => { - this._notifications.showSuccessSnackbar('Dashboard created successfully'); - this._dashboardsUpdated.set('created'); - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || 'Failed to create dashboard'); - return EMPTY; - }), - ); + // Dashboard CRUD operations (Promise-based) + async createDashboard(connectionId: string, payload: CreateDashboardPayload): Promise { + try { + const dashboard = await firstValueFrom(this._http.post(`/dashboards/${connectionId}`, payload)); + this._notifications.showSuccessSnackbar('Dashboard created successfully'); + this._dashboardsUpdated.set('created'); + return dashboard; + } catch (err: unknown) { + const error = err as { error?: { message?: string } }; + console.log(err); + this._notifications.showErrorSnackbar(error?.error?.message || 'Failed to create dashboard'); + return null; + } } - updateDashboard(connectionId: string, dashboardId: string, payload: UpdateDashboardPayload): Observable { - return this._http.put(`/dashboard/${dashboardId}/${connectionId}`, payload).pipe( - tap(() => { - this._notifications.showSuccessSnackbar('Dashboard updated successfully'); - this._dashboardsUpdated.set('updated'); - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || 'Failed to update dashboard'); - return EMPTY; - }), - ); + async updateDashboard( + connectionId: string, + dashboardId: string, + payload: UpdateDashboardPayload, + ): Promise { + try { + const dashboard = await firstValueFrom( + this._http.put(`/dashboard/${dashboardId}/${connectionId}`, payload), + ); + this._notifications.showSuccessSnackbar('Dashboard updated successfully'); + this._dashboardsUpdated.set('updated'); + return dashboard; + } catch (err: unknown) { + const error = err as { error?: { message?: string } }; + console.log(err); + this._notifications.showErrorSnackbar(error?.error?.message || 'Failed to update dashboard'); + return null; + } } - deleteDashboard(connectionId: string, dashboardId: string): Observable { - return this._http.delete(`/dashboard/${dashboardId}/${connectionId}`).pipe( - tap(() => { - this._notifications.showSuccessSnackbar('Dashboard deleted successfully'); - this._dashboardsUpdated.set('deleted'); - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || 'Failed to delete dashboard'); - return EMPTY; - }), - ); + async deleteDashboard(connectionId: string, dashboardId: string): Promise { + try { + const dashboard = await firstValueFrom(this._http.delete(`/dashboard/${dashboardId}/${connectionId}`)); + this._notifications.showSuccessSnackbar('Dashboard deleted successfully'); + this._dashboardsUpdated.set('deleted'); + return dashboard; + } catch (err: unknown) { + const error = err as { error?: { message?: string } }; + console.log(err); + this._notifications.showErrorSnackbar(error?.error?.message || 'Failed to delete dashboard'); + return null; + } } - // Widget CRUD operations - createWidget(connectionId: string, dashboardId: string, payload: CreateWidgetPayload): Observable { - return this._http.post(`/dashboard/${dashboardId}/widget/${connectionId}`, payload).pipe( - tap(() => { - this._notifications.showSuccessSnackbar('Widget created successfully'); - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || 'Failed to create widget'); - return EMPTY; - }), - ); + // Widget CRUD operations (Promise-based) + async createWidget( + connectionId: string, + dashboardId: string, + payload: CreateWidgetPayload, + ): Promise { + try { + const widget = await firstValueFrom( + this._http.post(`/dashboard/${dashboardId}/widget/${connectionId}`, payload), + ); + this._notifications.showSuccessSnackbar('Widget created successfully'); + return widget; + } catch (err: unknown) { + const error = err as { error?: { message?: string } }; + console.log(err); + this._notifications.showErrorSnackbar(error?.error?.message || 'Failed to create widget'); + return null; + } } - updateWidget( + async updateWidget( connectionId: string, dashboardId: string, widgetId: string, payload: UpdateWidgetPayload, - ): Observable { - return this._http - .put(`/dashboard/${dashboardId}/widget/${widgetId}/${connectionId}`, payload) - .pipe( - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || 'Failed to update widget'); - return EMPTY; - }), + ): Promise { + try { + const widget = await firstValueFrom( + this._http.put(`/dashboard/${dashboardId}/widget/${widgetId}/${connectionId}`, payload), ); + return widget; + } catch (err: unknown) { + const error = err as { error?: { message?: string } }; + console.log(err); + this._notifications.showErrorSnackbar(error?.error?.message || 'Failed to update widget'); + return null; + } } - updateWidgetPosition( + async updateWidgetPosition( connectionId: string, dashboardId: string, widgetId: string, payload: Pick, - ): Observable { - return this._http - .put(`/dashboard/${dashboardId}/widget/${widgetId}/${connectionId}`, payload) - .pipe( - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || 'Failed to update widget position'); - return EMPTY; - }), + ): Promise { + try { + const widget = await firstValueFrom( + this._http.put(`/dashboard/${dashboardId}/widget/${widgetId}/${connectionId}`, payload), ); + return widget; + } catch (err: unknown) { + const error = err as { error?: { message?: string } }; + console.log(err); + this._notifications.showErrorSnackbar(error?.error?.message || 'Failed to update widget position'); + return null; + } } - deleteWidget(connectionId: string, dashboardId: string, widgetId: string): Observable { - return this._http.delete(`/dashboard/${dashboardId}/widget/${widgetId}/${connectionId}`).pipe( - tap(() => { - this._notifications.showSuccessSnackbar('Widget deleted successfully'); - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || 'Failed to delete widget'); - return EMPTY; - }), - ); + async deleteWidget(connectionId: string, dashboardId: string, widgetId: string): Promise { + try { + const widget = await firstValueFrom( + this._http.delete(`/dashboard/${dashboardId}/widget/${widgetId}/${connectionId}`), + ); + this._notifications.showSuccessSnackbar('Widget deleted successfully'); + return widget; + } catch (err: unknown) { + const error = err as { error?: { message?: string } }; + console.log(err); + this._notifications.showErrorSnackbar(error?.error?.message || 'Failed to delete widget'); + return null; + } } } diff --git a/frontend/yarn.lock b/frontend/yarn.lock index de39dd1d6..b887bba4d 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -6470,6 +6470,16 @@ __metadata: languageName: node linkType: hard +"chartjs-adapter-date-fns@npm:^3.0.0": + version: 3.0.0 + resolution: "chartjs-adapter-date-fns@npm:3.0.0" + peerDependencies: + chart.js: ">=2.8.0" + date-fns: ">=2.0.0" + checksum: c39bfdf490749faa589fba6e0dc0a2c5a467a5f06aaa52c5180e10fd9c83414807a064f6950538448bffee354c8064c0ce9d957bccbf2048e7ee2257aa95f138 + languageName: node + linkType: hard + "check-error@npm:^2.1.1": version: 2.1.3 resolution: "check-error@npm:2.1.3" @@ -11729,6 +11739,7 @@ __metadata: angular-password-strength-meter: "npm:@eresearchqut/angular-password-strength-meter@^13.0.7" angulartics2: ^14.1.0 chart.js: ^4.5.1 + chartjs-adapter-date-fns: ^3.0.0 color-string: ^2.0.1 convert: ^5.12.0 date-fns: ^4.1.0 From 5903b3e961df8eb95b56ab0a1e8adfcfe2efe9a1 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Thu, 5 Feb 2026 13:55:36 +0000 Subject: [PATCH 4/5] fix: register Chart.js components in chart-widget test Add provideCharts(withDefaultRegisterables()) to test providers to fix "bar is not a registered controller" error. Co-Authored-By: Claude Opus 4.5 --- .../chart-widget/chart-widget.component.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.spec.ts b/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.spec.ts index 32dd180fc..676a80ae0 100644 --- a/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.spec.ts +++ b/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.spec.ts @@ -2,6 +2,7 @@ import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { provideCharts, withDefaultRegisterables } from 'ng2-charts'; import { ChartWidgetComponent } from './chart-widget.component'; describe('ChartWidgetComponent', () => { @@ -11,7 +12,7 @@ describe('ChartWidgetComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ChartWidgetComponent, BrowserAnimationsModule], - providers: [provideHttpClient(), provideHttpClientTesting()], + providers: [provideHttpClient(), provideHttpClientTesting(), provideCharts(withDefaultRegisterables())], }).compileComponents(); fixture = TestBed.createComponent(ChartWidgetComponent); From 3730018925e98e6c04d3366d5adac615b2265610 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Thu, 5 Feb 2026 14:14:28 +0000 Subject: [PATCH 5/5] fix: update chart-edit test to expect default label_type The component now includes label_type: 'values' in widget_options for bar/line charts by default. Co-Authored-By: Claude Opus 4.5 --- .../components/charts/chart-edit/chart-edit.component.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/components/charts/chart-edit/chart-edit.component.spec.ts b/frontend/src/app/components/charts/chart-edit/chart-edit.component.spec.ts index 08429d6c9..385b1cc6c 100644 --- a/frontend/src/app/components/charts/chart-edit/chart-edit.component.spec.ts +++ b/frontend/src/app/components/charts/chart-edit/chart-edit.component.spec.ts @@ -233,7 +233,7 @@ describe('ChartEditComponent', () => { query_text: 'SELECT 1', widget_type: 'chart', chart_type: 'bar', - widget_options: undefined, + widget_options: { label_type: 'values' }, }); });