diff --git a/frontend/package.json b/frontend/package.json
index 4ecac7375..56840f647 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -36,9 +36,11 @@
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2",
"amplitude-js": "^8.21.9",
+ "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",
+ "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/app-routing.module.ts b/frontend/src/app/app-routing.module.ts
index 11a0acaea..e2ff99f46 100644
--- a/frontend/src/app/app-routing.module.ts
+++ b/frontend/src/app/app-routing.module.ts
@@ -214,6 +214,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/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.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 42358bdc6..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,54 +57,72 @@
SQL Query
-
-
+
+
+
-
-
- Chart Type
-
-
- {{ type.label }}
-
-
-
-
-
- Label Column
-
-
- {{ col }}
-
-
-
-
-
- Value Column
-
-
- {{ col }}
-
-
-
-
+
+
+ Chart Type
+
+
+ {{ type.label }}
+
+
+
+
+
+ Label Column
+
+
+ {{ col }}
+
+
+
-
-
-
+
+ Label Type
+
+
+ {{ type.label }}
+
+
+
+
+
+ Value Column
+
+
+ {{ col }}
+
+
+
+
-
0">
-
Select label and value columns to display the chart
+
+
+
+
+
+
0">
+
Select label and value columns to display the chart
+
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..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
@@ -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: { label_type: 'values' },
});
});
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..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';
@@ -15,18 +16,10 @@ 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'] = {
- responsive: true,
- maintainAspectRatio: false,
- plugins: {
- legend: {
- display: true,
- position: 'top',
- },
- },
- };
+ public chartOptions: ChartConfiguration['options'] = this._getDefaultOptions();
private colorPalette = [
'rgba(99, 102, 241, 0.8)',
@@ -42,7 +35,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();
}
}
@@ -51,46 +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) => String(row[this.labelColumn] ?? ''));
- 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 _parseDate(value: unknown): Date | null {
+ if (!value) return null;
+
+ try {
+ const date = new Date(value as string | number | Date);
+ if (isNaN(date.getTime())) {
+ return null;
+ }
+ return date;
+ } catch {
+ return null;
}
}
}
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-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..fef945f50
--- /dev/null
+++ b/frontend/src/app/components/dashboards/dashboard-delete-dialog/dashboard-delete-dialog.component.ts
@@ -0,0 +1,37 @@
+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,
+ ) {}
+
+ async onDelete(): Promise {
+ this.submitting.set(true);
+ 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.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' }}
+
+
+
+
+
+
+
+
+
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..6ccd14a0c
--- /dev/null
+++ b/frontend/src/app/components/dashboards/dashboard-edit-dialog/dashboard-edit-dialog.component.ts
@@ -0,0 +1,74 @@
+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)]],
+ });
+ }
+
+ async onSubmit(): Promise {
+ if (this.form.invalid) return;
+
+ this.submitting.set(true);
+ const payload = this.form.value;
+
+ if (this.isEdit) {
+ 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 {
+ 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.css b/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.css
new file mode 100644
index 000000000..596df87b5
--- /dev/null
+++ b/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.css
@@ -0,0 +1,287 @@
+/* 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;
+}
+
+app-dashboard-view .dashboard-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 24px;
+ flex-wrap: wrap;
+ gap: 16px;
+}
+
+app-dashboard-view .header-left {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+app-dashboard-view .header-info {
+ display: flex;
+ flex-direction: column;
+}
+
+app-dashboard-view .header-info h1 {
+ margin: 0;
+ line-height: 1.2;
+}
+
+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) {
+ app-dashboard-view .dashboard-description {
+ color: rgba(255, 255, 255, 0.7);
+ }
+}
+
+app-dashboard-view .header-actions {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+app-dashboard-view .loading-container {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ flex: 1;
+}
+
+app-dashboard-view .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) {
+ app-dashboard-view .no-widgets {
+ background-color: rgba(255, 255, 255, 0.05);
+ }
+}
+
+app-dashboard-view .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) {
+ app-dashboard-view .no-widgets-icon {
+ color: rgba(255, 255, 255, 0.3);
+ }
+}
+
+app-dashboard-view .no-widgets h3 {
+ margin: 0 0 8px 0;
+ color: rgba(0, 0, 0, 0.87);
+}
+
+@media (prefers-color-scheme: dark) {
+ app-dashboard-view .no-widgets h3 {
+ color: rgba(255, 255, 255, 0.87);
+ }
+}
+
+app-dashboard-view .no-widgets p {
+ margin: 0 0 16px 0;
+ color: rgba(0, 0, 0, 0.54);
+}
+
+@media (prefers-color-scheme: dark) {
+ app-dashboard-view .no-widgets p {
+ color: rgba(255, 255, 255, 0.54);
+ }
+}
+
+app-dashboard-view .dashboard-grid {
+ flex: 1;
+ background-color: rgba(0, 0, 0, 0.02);
+ border-radius: 8px;
+}
+
+@media (prefers-color-scheme: dark) {
+ app-dashboard-view .dashboard-grid {
+ background-color: rgba(255, 255, 255, 0.02);
+ }
+}
+
+app-dashboard-view .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) {
+ app-dashboard-view .widget-item {
+ background: #1e1e1e;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
+ }
+}
+
+app-dashboard-view .widget-item.edit-mode {
+ border: 2px dashed var(--mdc-filled-button-container-color, #1976d2);
+}
+
+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;
+ padding: 8px 8px 8px 16px;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.12);
+ flex-shrink: 0;
+}
+
+@media (prefers-color-scheme: dark) {
+ app-dashboard-view .widget-header {
+ border-bottom-color: rgba(255, 255, 255, 0.12);
+ }
+}
+
+app-dashboard-view .widget-title {
+ font-weight: 500;
+ font-size: 14px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+app-dashboard-view .widget-menu-button {
+ flex-shrink: 0;
+}
+
+app-dashboard-view .widget-content {
+ flex: 1;
+ padding: 16px;
+ overflow: auto;
+ display: flex;
+ flex-direction: column;
+}
+
+app-dashboard-view .delete-action {
+ color: #c62828;
+}
+
+@media (prefers-color-scheme: dark) {
+ app-dashboard-view .delete-action {
+ color: #ef5350;
+ }
+}
+
+@media (width <= 600px) {
+ app-dashboard-view .dashboard-view-page {
+ padding: 16px;
+ }
+
+ app-dashboard-view .dashboard-header {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ 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
new file mode 100644
index 000000000..0496d6e8d
--- /dev/null
+++ b/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.html
@@ -0,0 +1,85 @@
+
+
+
+
+
+ @if (loading()) {
+
+
+
+ }
+
+ @if (!loading() && gridsterItems().length === 0) {
+
+ }
+
+ @if (!loading() && gridsterItems().length > 0) {
+
+ @for (item of gridsterItems(); track item.widget.id) {
+
+
+
+
+ }
+
+ }
+
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..a8494e401
--- /dev/null
+++ b/frontend/src/app/components/dashboards/dashboard-view/dashboard-view.component.ts
@@ -0,0 +1,266 @@
+import { CommonModule } from '@angular/common';
+import { Component, computed, DestroyRef, effect, inject, OnInit, signal, ViewEncapsulation } from '@angular/core';
+import { takeUntilDestroyed } 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,
+ GridsterComponent,
+ GridsterConfig,
+ GridsterItem,
+ GridsterItemComponent,
+ 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 { DashboardWidgetComponent } from '../widget-renderers/dashboard-widget/dashboard-widget.component';
+
+interface GridsterWidgetItem extends GridsterItem {
+ widget: DashboardWidget;
+}
+
+@Component({
+ selector: 'app-dashboard-view',
+ templateUrl: './dashboard-view.component.html',
+ styleUrls: ['./dashboard-view.component.css'],
+ encapsulation: ViewEncapsulation.None,
+ imports: [
+ CommonModule,
+ RouterModule,
+ MatButtonModule,
+ MatIconModule,
+ MatMenuModule,
+ MatTooltipModule,
+ MatSlideToggleModule,
+ MatProgressSpinnerModule,
+ GridsterComponent,
+ GridsterItemComponent,
+ AlertComponent,
+ DashboardWidgetComponent,
+ ],
+})
+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);
+ private destroyRef = inject(DestroyRef);
+
+ // Use service signals
+ protected dashboard = computed(() => this._dashboards.dashboard());
+ protected loading = computed(() => this._dashboards.dashboardLoading());
+
+ // Writable signal for gridster items (gridster needs mutable items)
+ protected gridsterItems = signal([]);
+
+ // Connection title signal (bridging from legacy Observable-based service)
+ private connectionTitle = signal('');
+
+ protected gridsterOptions: GridsterConfig = {
+ gridType: GridType.Fit,
+ compactType: CompactType.None,
+ displayGrid: DisplayGrid.OnDragAndResize,
+ 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,
+ minRows: 8,
+ maxRows: 100,
+ defaultItemCols: 4,
+ defaultItemRows: 4,
+ minItemCols: 2,
+ minItemRows: 2,
+ maxItemCols: 12,
+ maxItemRows: 12,
+ itemChangeCallback: (item: GridsterItem) => this._onItemChange(item as GridsterWidgetItem),
+ };
+
+ constructor() {
+ // Subscribe to connection title (legacy service bridge)
+ this._connections
+ .getCurrentConnectionTitle()
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe((title) => this.connectionTitle.set(title));
+
+ // 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();
+ 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'}`,
+ });
+ }
+
+ async openAddWidgetDialog(): Promise {
+ const dialogRef = this.dialog.open(WidgetEditDialogComponent, {
+ width: '600px',
+ data: {
+ connectionId: this.connectionId(),
+ dashboardId: this.dashboardId(),
+ widget: null,
+ },
+ });
+ this.angulartics2.eventTrack.next({
+ action: 'Dashboards: add widget dialog opened',
+ });
+
+ const result = await dialogRef.afterClosed().toPromise();
+ if (result) {
+ this._dashboards.refreshDashboard();
+ }
+ }
+
+ async openEditWidgetDialog(widget: DashboardWidget): Promise {
+ const dialogRef = this.dialog.open(WidgetEditDialogComponent, {
+ width: '600px',
+ data: {
+ connectionId: this.connectionId(),
+ dashboardId: this.dashboardId(),
+ widget: widget,
+ },
+ });
+ this.angulartics2.eventTrack.next({
+ action: 'Dashboards: edit widget dialog opened',
+ });
+
+ const result = await dialogRef.afterClosed().toPromise();
+ if (result) {
+ this._dashboards.refreshDashboard();
+ }
+ }
+
+ async openDeleteWidgetDialog(widget: DashboardWidget): Promise {
+ const dialogRef = this.dialog.open(WidgetDeleteDialogComponent, {
+ width: '400px',
+ data: {
+ connectionId: this.connectionId(),
+ dashboardId: this.dashboardId(),
+ widget: widget,
+ },
+ });
+ this.angulartics2.eventTrack.next({
+ action: 'Dashboards: delete widget dialog opened',
+ });
+
+ const result = await dialogRef.afterClosed().toPromise();
+ if (result) {
+ this._dashboards.refreshDashboard();
+ }
+ }
+
+ navigateBack(): void {
+ this.router.navigate(['/dashboards', this.connectionId()]);
+ }
+
+ private async _onItemChange(item: GridsterWidgetItem): Promise {
+ const widget = item.widget;
+ if (
+ widget.position_x !== item.x ||
+ widget.position_y !== item.y ||
+ 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
+ await this._dashboards.updateWidgetPosition(this.connectionId(), this.dashboardId(), widget.id, {
+ position_x: widget.position_x,
+ position_y: widget.position_y,
+ width: widget.width,
+ height: widget.height,
+ });
+ }
+ }
+}
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 @@
+
+
+
+
+
+
+
+
+
+
+
dashboard
+
No dashboards found
+
No dashboards match your search criteria.
+
Create your first dashboard to visualize your data.
+
+
+
+
0" mat-table [dataSource]="filteredDashboards()" class="mat-elevation-z2 dashboards-table">
+
+
+ | 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..38074d176
--- /dev/null
+++ b/frontend/src/app/components/dashboards/dashboards-list/dashboards-list.component.ts
@@ -0,0 +1,136 @@
+import { CommonModule } from '@angular/common';
+import { Component, computed, DestroyRef, effect, inject, OnInit, signal } from '@angular/core';
+import { takeUntilDestroyed } 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);
+ private destroyRef = inject(DestroyRef);
+
+ // 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)),
+ );
+ });
+
+ // Connection title signal (bridging from legacy Observable-based service)
+ private connectionTitle = signal('');
+
+ constructor() {
+ // Subscribe to connection title (legacy service bridge)
+ this._connections
+ .getCurrentConnectionTitle()
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe((title) => this.connectionTitle.set(title));
+
+ // 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..8567c8525
--- /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 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-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..ef82d2255
--- /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,
+ ) {}
+
+ async onDelete(): Promise {
+ this.submitting.set(true);
+ const result = await this._dashboards.deleteWidget(
+ this.data.connectionId,
+ this.data.dashboardId,
+ this.data.widget.id,
+ );
+ if (result) {
+ this.angulartics2.eventTrack.next({
+ action: 'Dashboards: widget deleted successfully',
+ });
+ this.dialogRef.close(true);
+ }
+ 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..2e9f21fc6
--- /dev/null
+++ b/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.css
@@ -0,0 +1,16 @@
+.widget-form {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-width: 400px;
+}
+
+@media (width <= 600px) {
+ .widget-form {
+ min-width: auto;
+ }
+}
+
+.full-width {
+ width: 100%;
+}
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..2cacb6888
--- /dev/null
+++ b/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.html
@@ -0,0 +1,29 @@
+{{ isEdit ? 'Edit Widget' : 'Add Widget' }}
+
+
+
+
+
+
+
+
+
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..35de7712b
--- /dev/null
+++ b/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.spec.ts
@@ -0,0 +1,206 @@
+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 { 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(),
+ },
+ ];
+
+ function setupTestBed(widget: DashboardWidget | null) {
+ savedQueriesSignal = signal(mockSavedQueries);
+ mockDialogRef = { close: vi.fn() };
+
+ mockDashboardsService = {
+ createWidget: vi.fn().mockResolvedValue({ id: 'new-widget' }),
+ updateWidget: vi.fn().mockResolvedValue({ id: 'updated-widget' }),
+ } as Partial;
+
+ mockSavedQueriesService = {
+ savedQueries: savedQueriesSignal.asReadonly(),
+ setActiveConnection: vi.fn(),
+ };
+
+ return TestBed.configureTestingModule({
+ imports: [WidgetEditDialogComponent, BrowserAnimationsModule, Angulartics2Module.forRoot()],
+ providers: [
+ provideHttpClient(),
+ provideHttpClientTesting(),
+ {
+ provide: MAT_DIALOG_DATA,
+ useValue: { connectionId: 'test-conn', dashboardId: 'test-dash', widget },
+ },
+ { 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', async () => {
+ testable.form.get('query_id')?.setValue('query-1');
+ await testable.onSubmit();
+ await fixture.whenStable();
+
+ expect(mockDialogRef.close).toHaveBeenCalledWith(true);
+ });
+ });
+
+ 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', async () => {
+ testable.form.get('query_id')?.setValue('query-2');
+ await testable.onSubmit();
+ await fixture.whenStable();
+
+ expect(mockDialogRef.close).toHaveBeenCalledWith(true);
+ });
+ });
+
+ 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
new file mode 100644
index 000000000..30f4facac
--- /dev/null
+++ b/frontend/src/app/components/dashboards/widget-edit-dialog/widget-edit-dialog.component.ts
@@ -0,0 +1,90 @@
+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 { 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, MatSelectModule],
+})
+export class WidgetEditDialogComponent implements OnInit {
+ protected submitting = signal(false);
+ protected form!: FormGroup;
+ protected isEdit: boolean;
+
+ 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({
+ query_id: [widget?.query_id || '', [Validators.required]],
+ });
+ }
+
+ async onSubmit(): Promise {
+ if (this.form.invalid) return;
+
+ this.submitting.set(true);
+ const formValue = this.form.value;
+
+ if (this.isEdit) {
+ const payload = {
+ query_id: formValue.query_id,
+ };
+
+ const result = await this._dashboards.updateWidget(
+ this.data.connectionId,
+ this.data.dashboardId,
+ this.data.widget!.id,
+ payload,
+ );
+ if (result) {
+ this.angulartics2.eventTrack.next({
+ action: 'Dashboards: widget updated successfully',
+ });
+ this.dialogRef.close(true);
+ }
+ } else {
+ const payload = {
+ query_id: formValue.query_id,
+ position_x: 0,
+ position_y: 0,
+ width: 4,
+ height: 4,
+ };
+
+ const result = await this._dashboards.createWidget(this.data.connectionId, this.data.dashboardId, payload);
+ if (result) {
+ this.angulartics2.eventTrack.next({
+ action: 'Dashboards: widget created successfully',
+ });
+ this.dialogRef.close(true);
+ }
+ }
+ 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..292e310f6
--- /dev/null
+++ b/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.html
@@ -0,0 +1,13 @@
+@if (chartData()) {
+
+
+
+} @else {
+
+}
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..676a80ae0
--- /dev/null
+++ b/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.spec.ts
@@ -0,0 +1,64 @@
+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', () => {
+ let component: ChartWidgetComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ChartWidgetComponent, BrowserAnimationsModule],
+ providers: [provideHttpClient(), provideHttpClientTesting(), provideCharts(withDefaultRegisterables())],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ChartWidgetComponent);
+ component = fixture.componentInstance;
+ component.widget = {
+ id: 'test-id',
+ position_x: 0,
+ position_y: 0,
+ width: 4,
+ height: 4,
+ 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
new file mode 100644
index 000000000..53f267340
--- /dev/null
+++ b/frontend/src/app/components/dashboards/widget-renderers/chart-widget/chart-widget.component.ts
@@ -0,0 +1,232 @@
+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';
+
+@Component({
+ selector: 'app-chart-widget',
+ templateUrl: './chart-widget.component.html',
+ styleUrls: ['./chart-widget.component.css'],
+ imports: [CommonModule, BaseChartDirective, MatProgressSpinnerModule],
+})
+export class ChartWidgetComponent implements OnInit {
+ @Input({ required: true }) widget!: DashboardWidget;
+ @Input({ required: true }) connectionId!: string;
+ @Input() preloadedQuery: SavedQuery | null = null;
+ @Input() preloadedData: Record[] = [];
+
+ protected data = signal[]>([]);
+ protected savedQuery = signal(null);
+
+ protected chartData = computed | null>(() => {
+ const data = this.data();
+ const query = this.savedQuery();
+ if (!data.length || !query) return null;
+
+ 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 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);
+
+ return {
+ datasets: [
+ {
+ label: valueColumn,
+ 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 = 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)',
+ '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)',
+ ];
+
+ 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.savedQuery()?.chart_type || 'bar') as ChartJsType;
+ }
+
+ private _getLabelColumn(query: SavedQuery, data: Record[]): string | null {
+ const labelCol = query.widget_options?.['label_column'] as string | undefined;
+ if (labelCol) return labelCol;
+
+ if (!data.length) return null;
+ return Object.keys(data[0])[0] || 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 _parseDate(value: unknown): Date | null {
+ if (!value) return null;
+
+ try {
+ const date = new Date(value as string | number | Date);
+ if (isNaN(date.getTime())) {
+ return null;
+ }
+ return date;
+ } catch {
+ 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/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..9572b5838
--- /dev/null
+++ b/frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.html
@@ -0,0 +1,12 @@
+@if (counterValue() !== null) {
+
+ {{ counterValue() }}
+ @if (label()) {
+ {{ label() }}
+ }
+
+} @else {
+
+}
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..36807049c
--- /dev/null
+++ b/frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.spec.ts
@@ -0,0 +1,54 @@
+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 { CounterWidgetComponent } from './counter-widget.component';
+
+describe('CounterWidgetComponent', () => {
+ let component: CounterWidgetComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [CounterWidgetComponent, BrowserAnimationsModule],
+ providers: [provideHttpClient(), provideHttpClientTesting()],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(CounterWidgetComponent);
+ component = fixture.componentInstance;
+ component.widget = {
+ id: 'test-id',
+ position_x: 0,
+ position_y: 0,
+ width: 2,
+ height: 2,
+ 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
new file mode 100644
index 000000000..e7107c03e
--- /dev/null
+++ b/frontend/src/app/components/dashboards/widget-renderers/counter-widget/counter-widget.component.ts
@@ -0,0 +1,76 @@
+import { CommonModule } from '@angular/common';
+import { Component, computed, Input, OnInit, signal } from '@angular/core';
+import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
+import { DashboardWidget } from 'src/app/models/dashboard';
+import { SavedQuery } from 'src/app/models/saved-query';
+
+@Component({
+ selector: 'app-counter-widget',
+ templateUrl: './counter-widget.component.html',
+ styleUrls: ['./counter-widget.component.css'],
+ imports: [CommonModule, MatProgressSpinnerModule],
+})
+export class CounterWidgetComponent implements OnInit {
+ @Input({ required: true }) widget!: DashboardWidget;
+ @Input({ required: true }) connectionId!: string;
+ @Input() preloadedQuery: SavedQuery | null = null;
+ @Input() preloadedData: Record[] = [];
+
+ protected data = signal[]>([]);
+ protected savedQuery = signal(null);
+
+ protected counterValue = computed(() => {
+ const data = this.data();
+ const query = this.savedQuery();
+ if (!data.length || !query) return null;
+
+ const valueColumn = this._getValueColumn(query, data);
+ 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();
+ const query = this.savedQuery();
+ if (!data.length || !query) return '';
+
+ const labelColumn = query.widget_options?.['label_column'] as string | undefined;
+ if (labelColumn && data[0][labelColumn]) {
+ return String(data[0][labelColumn]);
+ }
+
+ const valueColumn = this._getValueColumn(query, data);
+ return valueColumn || '';
+ });
+
+ ngOnInit(): void {
+ if (this.preloadedQuery) {
+ this.savedQuery.set(this.preloadedQuery);
+ }
+ if (this.preloadedData.length > 0) {
+ this.data.set(this.preloadedData);
+ }
+ }
+
+ 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;
+ return Object.keys(data[0])[0] || null;
+ }
+
+ 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/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..b41b6a23c
--- /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 { 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';
+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 async _loadData(): Promise {
+ 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);
+
+ 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/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..b9eea58c7
--- /dev/null
+++ b/frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.html
@@ -0,0 +1,18 @@
+@if (data().length > 0) {
+
+
+ @for (column of columns(); track column) {
+
+ | {{ column }} |
+ {{ row[column] }} |
+
+ }
+
+
+
+
+} @else {
+
+}
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..59f9626f3
--- /dev/null
+++ b/frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.spec.ts
@@ -0,0 +1,62 @@
+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 { TableWidgetComponent } from './table-widget.component';
+
+describe('TableWidgetComponent', () => {
+ let component: TableWidgetComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [TableWidgetComponent, BrowserAnimationsModule],
+ providers: [provideHttpClient(), provideHttpClientTesting()],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(TableWidgetComponent);
+ component = fixture.componentInstance;
+ component.widget = {
+ id: 'test-id',
+ position_x: 0,
+ position_y: 0,
+ width: 4,
+ height: 4,
+ 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
new file mode 100644
index 000000000..77e624c66
--- /dev/null
+++ b/frontend/src/app/components/dashboards/widget-renderers/table-widget/table-widget.component.ts
@@ -0,0 +1,33 @@
+import { CommonModule } from '@angular/common';
+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 { SavedQuery } from 'src/app/models/saved-query';
+
+@Component({
+ selector: 'app-table-widget',
+ templateUrl: './table-widget.component.html',
+ styleUrls: ['./table-widget.component.css'],
+ imports: [CommonModule, MatTableModule, MatProgressSpinnerModule],
+})
+export class TableWidgetComponent implements OnInit {
+ @Input({ required: true }) widget!: DashboardWidget;
+ @Input({ required: true }) connectionId!: string;
+ @Input() preloadedQuery: SavedQuery | null = null;
+ @Input() preloadedData: Record[] = [];
+
+ protected data = signal[]>([]);
+
+ protected columns = computed(() => {
+ const data = this.data();
+ if (!data.length) return [];
+ return Object.keys(data[0]);
+ });
+
+ ngOnInit(): void {
+ if (this.preloadedData.length > 0) {
+ this.data.set(this.preloadedData);
+ }
+ }
+}
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..b25658f12
--- /dev/null
+++ b/frontend/src/app/components/dashboards/widget-renderers/text-widget/text-widget.component.spec.ts
@@ -0,0 +1,49 @@
+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',
+ position_x: 0,
+ position_y: 0,
+ width: 4,
+ height: 4,
+ 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
new file mode 100644
index 000000000..d0e1cb1a9
--- /dev/null
+++ b/frontend/src/app/components/dashboards/widget-renderers/text-widget/text-widget.component.ts
@@ -0,0 +1,22 @@
+import { CommonModule } from '@angular/common';
+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',
+ templateUrl: './text-widget.component.html',
+ styleUrls: ['./text-widget.component.css'],
+ imports: [CommonModule, MarkdownModule],
+})
+export class TextWidgetComponent {
+ @Input({ required: true }) widget!: DashboardWidget;
+ @Input() preloadedQuery: SavedQuery | null = null;
+
+ protected textContent = computed(() => {
+ 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
new file mode 100644
index 000000000..61a47242d
--- /dev/null
+++ b/frontend/src/app/models/dashboard.ts
@@ -0,0 +1,45 @@
+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;
+ position_x: number;
+ position_y: number;
+ width: number;
+ height: number;
+ query_id: string | null;
+ dashboard_id: string;
+}
+
+export interface CreateDashboardPayload {
+ name: string;
+ description?: string;
+}
+
+export interface UpdateDashboardPayload {
+ name?: string;
+ description?: string;
+}
+
+export interface CreateWidgetPayload {
+ position_x: number;
+ position_y: number;
+ width: number;
+ height: number;
+ query_id: string;
+}
+
+export interface UpdateWidgetPayload {
+ position_x?: number;
+ position_y?: number;
+ width?: number;
+ height?: number;
+ 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/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..74a9e3580
--- /dev/null
+++ b/frontend/src/app/services/dashboards.service.ts
@@ -0,0 +1,213 @@
+import { HttpClient } from '@angular/common/http';
+import { computed, Injectable, inject, ResourceRef, resource, signal } from '@angular/core';
+import { firstValueFrom } from 'rxjs';
+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 (using pure signal-based resource with HttpClient)
+ private _dashboardsResource: ResourceRef = resource({
+ params: () => this._activeConnectionId(),
+ 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: ResourceRef = resource({
+ params: () => ({ connectionId: this._activeConnectionId(), dashboardId: this._activeDashboardId() }),
+ 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;
+ }
+ },
+ });
+
+ // 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 (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;
+ }
+ }
+
+ 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;
+ }
+ }
+
+ 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 (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;
+ }
+ }
+
+ async updateWidget(
+ connectionId: string,
+ dashboardId: string,
+ widgetId: string,
+ payload: UpdateWidgetPayload,
+ ): 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;
+ }
+ }
+
+ async updateWidgetPosition(
+ connectionId: string,
+ dashboardId: string,
+ widgetId: string,
+ payload: Pick,
+ ): 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;
+ }
+ }
+
+ 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 7502ab602..b159386fa 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -6108,6 +6108,19 @@ __metadata:
languageName: node
linkType: hard
+"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": ^20.0.0
+ "@angular/core": ^20.0.0
+ rxjs: ^7.0.0
+ checksum: 2c62a5c9ff1cbf7930f4a886fc0eb769f2f6fc5e41dd75778e7a6873c4510c4e0ec1c562b00da9c3dccd37291c3c0b2e21aab2c1901c04514c68b25cf0ad4fb9
+ 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"
@@ -6697,6 +6710,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"
@@ -12021,9 +12044,11 @@ __metadata:
"@zxcvbn-ts/core": ^3.0.4
"@zxcvbn-ts/language-en": ^3.0.2
amplitude-js: ^8.21.9
+ 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
+ chartjs-adapter-date-fns: ^3.0.0
color-string: ^2.0.1
convert: ^5.12.0
date-fns: ^4.1.0