Skip to content

Commit 468d78a

Browse files
guguclaude
andauthored
feat: add dashboards feature with draggable chart widgets (#1552)
* feat: add dashboards feature with draggable chart widgets - Add dashboards list page with search, create, edit, delete functionality - Add dashboard view with Gridster-based drag/resize grid for charts - Add chart widget renderer using ng2-charts (bar, line, pie, doughnut, polar) - Add widget edit dialog for adding charts linked to saved queries - Add dashboards service with rxResource for reactive data fetching - Replace Charts nav tab with Dashboards tab - Add link to manage saved queries from dashboards page Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: refactor widget structure and add chart label type option - Refactor widget to only contain positioning data (position_x, position_y, width, height, query_id) - Move widget display properties (widget_type, chart_type, widget_options) to SavedQuery - Add dashboard-widget component to handle data fetching and widget rendering - Display widget name from saved query in widget header - Add drag and resize support for widgets in edit mode using angular-gridster2 v20 - Add "Label Type" option for bar/line charts with "Values" and "Datetime" options - Update all widget renderer tests to use new structure with preloaded data Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: improve chart configuration UI and add time-based axis support - Separate chart configuration from preview section in chart-edit - Add "Configure Chart" link to dashboard widget menu - Rename "Edit" to "Change Query" for clarity - Add time-based X axis for datetime label type with gap support - Install chartjs-adapter-date-fns for Chart.js time scale Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: register Chart.js components in chart-widget test Add provideCharts(withDefaultRegisterables()) to test providers to fix "bar is not a registered controller" error. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent cdca5a9 commit 468d78a

59 files changed

Lines changed: 3693 additions & 87 deletions

File tree

Some content is hidden

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

frontend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,11 @@
3636
"@zxcvbn-ts/core": "^3.0.4",
3737
"@zxcvbn-ts/language-en": "^3.0.2",
3838
"amplitude-js": "^8.21.9",
39+
"angular-gridster2": "^20.0.0",
3940
"angular-password-strength-meter": "npm:@eresearchqut/angular-password-strength-meter@^13.0.7",
4041
"angulartics2": "^14.1.0",
4142
"chart.js": "^4.5.1",
43+
"chartjs-adapter-date-fns": "^3.0.0",
4244
"color-string": "^2.0.1",
4345
"convert": "^5.12.0",
4446
"date-fns": "^4.1.0",

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,22 @@ const routes: Routes = [
214214
canActivate: [AuthGuard],
215215
title: 'Edit Query | Rocketadmin',
216216
},
217+
{
218+
path: 'dashboards/:connection-id',
219+
loadComponent: () =>
220+
import('./components/dashboards/dashboards-list/dashboards-list.component').then(
221+
(m) => m.DashboardsListComponent,
222+
),
223+
canActivate: [AuthGuard],
224+
title: 'Dashboards | Rocketadmin',
225+
},
226+
{
227+
path: 'dashboards/:connection-id/:dashboard-id',
228+
loadComponent: () =>
229+
import('./components/dashboards/dashboard-view/dashboard-view.component').then((m) => m.DashboardViewComponent),
230+
canActivate: [AuthGuard],
231+
title: 'Dashboard | Rocketadmin',
232+
},
217233
{
218234
path: '**',
219235
loadComponent: () =>

frontend/src/app/app.component.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,8 @@ export class AppComponent {
194194
permissions: {
195195
caption: 'Permissions',
196196
},
197-
charts: {
198-
caption: 'Charts',
197+
dashboards: {
198+
caption: 'Dashboards',
199199
},
200200
'connection-settings': {
201201
caption: 'Connection settings',

frontend/src/app/components/charts/chart-delete-dialog/chart-delete-dialog.component.spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ describe('ChartDeleteDialogComponent', () => {
2525
id: '1',
2626
name: 'Test Query',
2727
description: 'Test description',
28+
widget_type: 'chart',
29+
chart_type: 'bar',
30+
widget_options: null,
2831
query_text: 'SELECT * FROM users',
2932
connection_id: 'conn-1',
3033
created_at: '2024-01-01',

frontend/src/app/components/charts/chart-edit/chart-edit.component.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@
7272
}
7373

7474
.editor-section,
75+
.right-panel {
76+
display: flex;
77+
flex-direction: column;
78+
gap: 24px;
79+
}
80+
81+
.config-section,
7582
.preview-section {
7683
display: flex;
7784
flex-direction: column;

frontend/src/app/components/charts/chart-edit/chart-edit.component.html

Lines changed: 62 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -57,54 +57,72 @@ <h3>SQL Query</h3>
5757
</div>
5858
</div>
5959

60-
<div class="preview-section" *ngIf="showResults()">
61-
<div class="section-header">
62-
<h3>Chart Preview</h3>
63-
<span class="execution-time" *ngIf="executionTime() !== null">
64-
Executed in {{ executionTime() }}ms
65-
</span>
66-
</div>
60+
<div class="right-panel" *ngIf="showResults()">
61+
<div class="config-section">
62+
<div class="section-header">
63+
<h3>Chart Configuration</h3>
64+
<span class="execution-time" *ngIf="executionTime() !== null">
65+
Executed in {{ executionTime() }}ms
66+
</span>
67+
</div>
6768

68-
<div class="chart-config">
69-
<mat-form-field appearance="outline" class="chart-config-field">
70-
<mat-label>Chart Type</mat-label>
71-
<mat-select [ngModel]="chartType()" (ngModelChange)="chartType.set($event)" data-testid="chart-type-select">
72-
<mat-option *ngFor="let type of chartTypes" [value]="type.value">
73-
{{ type.label }}
74-
</mat-option>
75-
</mat-select>
76-
</mat-form-field>
77-
78-
<mat-form-field appearance="outline" class="chart-config-field">
79-
<mat-label>Label Column</mat-label>
80-
<mat-select [ngModel]="labelColumn()" (ngModelChange)="labelColumn.set($event)" data-testid="label-column-select">
81-
<mat-option *ngFor="let col of resultColumns()" [value]="col">
82-
{{ col }}
83-
</mat-option>
84-
</mat-select>
85-
</mat-form-field>
86-
87-
<mat-form-field appearance="outline" class="chart-config-field">
88-
<mat-label>Value Column</mat-label>
89-
<mat-select [ngModel]="valueColumn()" (ngModelChange)="valueColumn.set($event)" data-testid="value-column-select">
90-
<mat-option *ngFor="let col of resultColumns()" [value]="col">
91-
{{ col }}
92-
</mat-option>
93-
</mat-select>
94-
</mat-form-field>
95-
</div>
69+
<div class="chart-config">
70+
<mat-form-field appearance="outline" class="chart-config-field">
71+
<mat-label>Chart Type</mat-label>
72+
<mat-select [ngModel]="chartType()" (ngModelChange)="chartType.set($event)" data-testid="chart-type-select">
73+
<mat-option *ngFor="let type of chartTypes" [value]="type.value">
74+
{{ type.label }}
75+
</mat-option>
76+
</mat-select>
77+
</mat-form-field>
78+
79+
<mat-form-field appearance="outline" class="chart-config-field">
80+
<mat-label>Label Column</mat-label>
81+
<mat-select [ngModel]="labelColumn()" (ngModelChange)="labelColumn.set($event)" data-testid="label-column-select">
82+
<mat-option *ngFor="let col of resultColumns()" [value]="col">
83+
{{ col }}
84+
</mat-option>
85+
</mat-select>
86+
</mat-form-field>
9687

97-
<div class="chart-container" *ngIf="hasChartData()">
98-
<app-chart-preview
99-
[chartType]="chartType()"
100-
[data]="testResults()"
101-
[labelColumn]="labelColumn()"
102-
[valueColumn]="valueColumn()">
103-
</app-chart-preview>
88+
<mat-form-field appearance="outline" class="chart-config-field" *ngIf="showLabelTypeOption()">
89+
<mat-label>Label Type</mat-label>
90+
<mat-select [ngModel]="labelType()" (ngModelChange)="labelType.set($event)" data-testid="label-type-select">
91+
<mat-option *ngFor="let type of labelTypes" [value]="type.value">
92+
{{ type.label }}
93+
</mat-option>
94+
</mat-select>
95+
</mat-form-field>
96+
97+
<mat-form-field appearance="outline" class="chart-config-field">
98+
<mat-label>Value Column</mat-label>
99+
<mat-select [ngModel]="valueColumn()" (ngModelChange)="valueColumn.set($event)" data-testid="value-column-select">
100+
<mat-option *ngFor="let col of resultColumns()" [value]="col">
101+
{{ col }}
102+
</mat-option>
103+
</mat-select>
104+
</mat-form-field>
105+
</div>
104106
</div>
105107

106-
<div class="no-chart-data" *ngIf="!hasChartData() && testResults().length > 0">
107-
<p>Select label and value columns to display the chart</p>
108+
<div class="preview-section">
109+
<div class="section-header">
110+
<h3>Chart Preview</h3>
111+
</div>
112+
113+
<div class="chart-container" *ngIf="hasChartData()">
114+
<app-chart-preview
115+
[chartType]="chartType()"
116+
[data]="testResults()"
117+
[labelColumn]="labelColumn()"
118+
[valueColumn]="valueColumn()"
119+
[labelType]="labelType()">
120+
</app-chart-preview>
121+
</div>
122+
123+
<div class="no-chart-data" *ngIf="!hasChartData() && testResults().length > 0">
124+
<p>Select label and value columns to display the chart</p>
125+
</div>
108126
</div>
109127
</div>
110128
</div>

frontend/src/app/components/charts/chart-edit/chart-edit.component.spec.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ describe('ChartEditComponent', () => {
4646
id: '1',
4747
name: 'Test Query',
4848
description: 'Test description',
49+
widget_type: 'chart',
50+
chart_type: 'bar',
51+
widget_options: { label_column: 'name', value_column: 'count' },
4952
query_text: 'SELECT * FROM users',
5053
connection_id: 'conn-1',
5154
created_at: '2024-01-01',
@@ -228,6 +231,9 @@ describe('ChartEditComponent', () => {
228231
name: 'New Query',
229232
description: undefined,
230233
query_text: 'SELECT 1',
234+
widget_type: 'chart',
235+
chart_type: 'bar',
236+
widget_options: { label_type: 'values' },
231237
});
232238
});
233239

frontend/src/app/components/charts/chart-edit/chart-edit.component.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export class ChartEditComponent implements OnInit {
6363
protected chartType = signal<ChartType>('bar');
6464
protected labelColumn = signal('');
6565
protected valueColumn = signal('');
66+
protected labelType = signal<'values' | 'datetime'>('values');
6667

6768
public chartTypes: { value: ChartType; label: string }[] = [
6869
{ value: 'bar', label: 'Bar Chart' },
@@ -72,6 +73,13 @@ export class ChartEditComponent implements OnInit {
7273
{ value: 'polarArea', label: 'Polar Area Chart' },
7374
];
7475

76+
public labelTypes: { value: 'values' | 'datetime'; label: string }[] = [
77+
{ value: 'values', label: 'Values' },
78+
{ value: 'datetime', label: 'Datetime' },
79+
];
80+
81+
protected showLabelTypeOption = computed(() => ['bar', 'line'].includes(this.chartType()));
82+
7583
// Use a signal for codeModel to ensure change detection works on load
7684
// Only update this signal when loading a query, not during typing (to preserve cursor position)
7785
protected codeModel = signal({
@@ -140,6 +148,21 @@ export class ChartEditComponent implements OnInit {
140148
this.queryName.set(query.name);
141149
this.queryDescription.set(query.description || '');
142150
this.queryText.set(query.query_text);
151+
// Load chart configuration
152+
if (query.chart_type) {
153+
this.chartType.set(query.chart_type);
154+
}
155+
if (query.widget_options) {
156+
if (query.widget_options['label_column']) {
157+
this.labelColumn.set(query.widget_options['label_column'] as string);
158+
}
159+
if (query.widget_options['value_column']) {
160+
this.valueColumn.set(query.widget_options['value_column'] as string);
161+
}
162+
if (query.widget_options['label_type']) {
163+
this.labelType.set(query.widget_options['label_type'] as 'values' | 'datetime');
164+
}
165+
}
143166
// Set codeModel value for Monaco editor (only on load, not during typing)
144167
this.codeModel.set({ language: 'sql', uri: 'query.sql', value: query.query_text });
145168
// Automatically test the query to show chart preview
@@ -190,10 +213,25 @@ export class ChartEditComponent implements OnInit {
190213

191214
this.saving.set(true);
192215

216+
// Build widget_options with column selections
217+
const widgetOptions: Record<string, unknown> = {};
218+
if (this.labelColumn()) {
219+
widgetOptions['label_column'] = this.labelColumn();
220+
}
221+
if (this.valueColumn()) {
222+
widgetOptions['value_column'] = this.valueColumn();
223+
}
224+
if (this.labelType() && this.showLabelTypeOption()) {
225+
widgetOptions['label_type'] = this.labelType();
226+
}
227+
193228
const payload = {
194229
name: this.queryName(),
195230
description: this.queryDescription() || undefined,
196231
query_text: this.queryText(),
232+
widget_type: 'chart' as const,
233+
chart_type: this.chartType(),
234+
widget_options: Object.keys(widgetOptions).length > 0 ? widgetOptions : undefined,
197235
};
198236

199237
if (this.isEditMode()) {

0 commit comments

Comments
 (0)