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 2fd8ec25c..3fe3da88b 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 @@ -6,7 +6,6 @@ import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/materia import { MatSnackBarModule } from '@angular/material/snack-bar'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { Angulartics2Module } from 'angulartics2'; -import { of, throwError } from 'rxjs'; import { SavedQuery } from 'src/app/models/saved-query'; import { SavedQueriesService } from 'src/app/services/saved-queries.service'; import { ChartDeleteDialogComponent } from './chart-delete-dialog.component'; @@ -36,7 +35,7 @@ describe('ChartDeleteDialogComponent', () => { beforeEach(async () => { mockSavedQueriesService = { - deleteSavedQuery: vi.fn().mockReturnValue(of(mockSavedQuery)), + deleteSavedQuery: vi.fn().mockResolvedValue(mockSavedQuery), }; mockDialogRef = { @@ -78,43 +77,39 @@ describe('ChartDeleteDialogComponent', () => { }); describe('onDelete', () => { - it('should call deleteSavedQuery service method', () => { - component.onDelete(); + it('should call deleteSavedQuery service method', async () => { + await component.onDelete(); expect(mockSavedQueriesService.deleteSavedQuery).toHaveBeenCalledWith('conn-1', '1'); }); it('should set submitting to true during delete', () => { component.onDelete(); - // The deleteSavedQuery returns synchronously in the mock, so submitting would be false after - // In real usage, submitting would be true while the request is in flight - expect(mockSavedQueriesService.deleteSavedQuery).toHaveBeenCalled(); + // The deleteSavedQuery returns asynchronously, so submitting should be true while in flight + const testable = component as ChartDeleteDialogComponentTestable; + expect(testable.submitting()).toBe(true); }); - it('should close dialog with true on success', () => { - component.onDelete(); + it('should close dialog with true on success', async () => { + await component.onDelete(); expect(mockDialogRef.close).toHaveBeenCalledWith(true); }); - it('should set submitting to false after successful delete', () => { + it('should set submitting to false after successful delete', async () => { const testable = component as ChartDeleteDialogComponentTestable; - component.onDelete(); + await component.onDelete(); expect(testable.submitting()).toBe(false); }); - it('should set submitting to false on error', () => { + it('should set submitting to false on error', async () => { const testable = component as ChartDeleteDialogComponentTestable; - vi.mocked(mockSavedQueriesService.deleteSavedQuery!).mockReturnValue( - throwError(() => new Error('Delete failed')), - ); - component.onDelete(); + vi.mocked(mockSavedQueriesService.deleteSavedQuery!).mockResolvedValue(null); + await component.onDelete(); expect(testable.submitting()).toBe(false); }); - it('should not close dialog on error', () => { - vi.mocked(mockSavedQueriesService.deleteSavedQuery!).mockReturnValue( - throwError(() => new Error('Delete failed')), - ); - component.onDelete(); + it('should not close dialog on error', async () => { + vi.mocked(mockSavedQueriesService.deleteSavedQuery!).mockResolvedValue(null); + await component.onDelete(); expect(mockDialogRef.close).not.toHaveBeenCalled(); }); }); diff --git a/frontend/src/app/components/charts/chart-delete-dialog/chart-delete-dialog.component.ts b/frontend/src/app/components/charts/chart-delete-dialog/chart-delete-dialog.component.ts index 98a5c4899..0231e1773 100644 --- a/frontend/src/app/components/charts/chart-delete-dialog/chart-delete-dialog.component.ts +++ b/frontend/src/app/components/charts/chart-delete-dialog/chart-delete-dialog.component.ts @@ -24,20 +24,17 @@ export class ChartDeleteDialogComponent { private angulartics2: Angulartics2, ) {} - onDelete(): void { + async onDelete(): Promise { this.submitting.set(true); - this._savedQueries.deleteSavedQuery(this.data.connectionId, this.data.query.id).subscribe({ - next: () => { - this.angulartics2.eventTrack.next({ - action: 'Charts: saved query deleted successfully', - }); - posthog.capture('Charts: saved query deleted successfully'); - this.submitting.set(false); - this.dialogRef.close(true); - }, - error: () => { - this.submitting.set(false); - }, - }); + const result = await this._savedQueries.deleteSavedQuery(this.data.connectionId, this.data.query.id); + this.submitting.set(false); + + if (result) { + this.angulartics2.eventTrack.next({ + action: 'Charts: saved query deleted successfully', + }); + posthog.capture('Charts: saved query deleted successfully'); + this.dialogRef.close(true); + } } } 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 6ecd23d51..6b9e18fc1 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 @@ -57,15 +57,13 @@ describe('ChartEditComponent', () => { beforeEach(async () => { mockSavedQueriesService = { - fetchSavedQuery: vi.fn().mockReturnValue(of(mockSavedQuery)), - createSavedQuery: vi.fn().mockReturnValue(of(mockSavedQuery)), - updateSavedQuery: vi.fn().mockReturnValue(of(mockSavedQuery)), - testQuery: vi.fn().mockReturnValue( - of({ - data: [{ name: 'John', count: 10 }], - execution_time_ms: 50, - }), - ), + fetchSavedQuery: vi.fn().mockResolvedValue(mockSavedQuery), + createSavedQuery: vi.fn().mockResolvedValue(mockSavedQuery), + updateSavedQuery: vi.fn().mockResolvedValue(mockSavedQuery), + testQuery: vi.fn().mockResolvedValue({ + data: [{ name: 'John', count: 10 }], + execution_time_ms: 50, + }), }; mockConnectionsService = { @@ -194,40 +192,40 @@ describe('ChartEditComponent', () => { }); describe('testQuery', () => { - it('should call testQuery service method', () => { + it('should call testQuery service method', async () => { const testable = component as ChartEditComponentTestable; testable.queryText.set('SELECT * FROM users'); - component.testQuery(); + await component.testQuery(); expect(mockSavedQueriesService.testQuery).toHaveBeenCalledWith('conn-1', { query_text: 'SELECT * FROM users', }); }); - it('should set results and columns after successful test', () => { + it('should set results and columns after successful test', async () => { const testable = component as ChartEditComponentTestable; testable.queryText.set('SELECT * FROM users'); - component.testQuery(); + await component.testQuery(); expect(testable.testResults()).toEqual([{ name: 'John', count: 10 }]); expect(testable.resultColumns()).toEqual(['name', 'count']); expect(testable.executionTime()).toBe(50); }); - it('should auto-select label column and first series', () => { + it('should auto-select label column and first series', async () => { const testable = component as ChartEditComponentTestable; testable.queryText.set('SELECT * FROM users'); - component.testQuery(); + await component.testQuery(); expect(testable.labelColumn()).toBe('name'); expect(testable.seriesList()).toEqual([{ value_column: 'count' }]); }); }); describe('saveQuery', () => { - it('should call createSavedQuery in create mode', () => { + it('should call createSavedQuery in create mode', async () => { const testable = component as ChartEditComponentTestable; testable.isEditMode.set(false); testable.queryName.set('New Query'); testable.queryText.set('SELECT 1'); - component.saveQuery(); + await component.saveQuery(); expect(mockSavedQueriesService.createSavedQuery).toHaveBeenCalledWith('conn-1', { name: 'New Query', description: undefined, @@ -238,11 +236,11 @@ describe('ChartEditComponent', () => { }); }); - it('should navigate to charts list after save', () => { + it('should navigate to charts list after save', async () => { const testable = component as ChartEditComponentTestable; testable.queryName.set('New Query'); testable.queryText.set('SELECT 1'); - component.saveQuery(); + await component.saveQuery(); expect(router.navigate).toHaveBeenCalledWith(['/panels', 'conn-1']); }); }); 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 cf99cfe43..99c05574a 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 @@ -17,7 +17,6 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { CodeEditorModule } from '@ngstack/code-editor'; import { Angulartics2 } from 'angulartics2'; import posthog from 'posthog-js'; -import { finalize } from 'rxjs/operators'; import { DEFAULT_COLOR_PALETTE } from 'src/app/lib/chart-config.helper'; import { ChartAxisConfig, @@ -389,133 +388,136 @@ export class ChartEditComponent implements OnInit { } } - loadSavedQuery(): void { + async loadSavedQuery(): Promise { this.loading.set(true); - this._savedQueries - .fetchSavedQuery(this.connectionId(), this.queryId()) - .pipe(finalize(() => this.loading.set(false))) - .subscribe((query) => { - if (query) { - this.queryName.set(query.name); - this.queryDescription.set(query.description || ''); - this.queryText.set(query.query_text); - - if (query.chart_type) { - this.chartType.set(query.chart_type); - } + try { + const query = await this._savedQueries.fetchSavedQuery(this.connectionId(), this.queryId()); - if (query.widget_options) { - const opts = query.widget_options as Partial; - - if (opts.label_column) this.labelColumn.set(opts.label_column); - if (opts.label_type) this.labelType.set(opts.label_type); - - // Load series - if (opts.series_column) { - this.seriesMode.set('column'); - this.seriesColumn.set(opts.series_column); - if (opts.value_column) this.seriesValueColumn.set(opts.value_column); - } else if (opts.series?.length) { - this.seriesList.set([...opts.series]); - } else if (opts.value_column) { - // Legacy: convert single value_column to series - this.seriesList.set([{ value_column: opts.value_column }]); - } + if (query) { + this.queryName.set(query.name); + this.queryDescription.set(query.description || ''); + this.queryText.set(query.query_text); - // Display options - if (opts.stacked) this.stacked.set(true); - if (opts.horizontal) this.horizontal.set(true); - if (opts.show_data_labels) this.showDataLabels.set(true); + if (query.chart_type) { + this.chartType.set(query.chart_type); + } - // Legend - if (opts.legend) { - if (opts.legend.show !== undefined) this.legendShow.set(opts.legend.show); - if (opts.legend.position) this.legendPosition.set(opts.legend.position); - } + if (query.widget_options) { + const opts = query.widget_options as Partial; + + if (opts.label_column) this.labelColumn.set(opts.label_column); + if (opts.label_type) this.labelType.set(opts.label_type); + + // Load series + if (opts.series_column) { + this.seriesMode.set('column'); + this.seriesColumn.set(opts.series_column); + if (opts.value_column) this.seriesValueColumn.set(opts.value_column); + } else if (opts.series?.length) { + this.seriesList.set([...opts.series]); + } else if (opts.value_column) { + // Legacy: convert single value_column to series + this.seriesList.set([{ value_column: opts.value_column }]); + } - // Units - if (opts.units) { - if (opts.units.convert_unit) { - this.unitMode.set('convert'); - this.convertUnit.set(opts.units.convert_unit); - } else if (opts.units.text) { - this.unitMode.set('custom'); - this.unitsText.set(opts.units.text); - if (opts.units.position) this.unitsPosition.set(opts.units.position); - } - } + // Display options + if (opts.stacked) this.stacked.set(true); + if (opts.horizontal) this.horizontal.set(true); + if (opts.show_data_labels) this.showDataLabels.set(true); - // Number format - if (opts.number_format) { - if (opts.number_format.decimal_places !== undefined) { - this.decimalPlaces.set(opts.number_format.decimal_places); - } - if (opts.number_format.thousands_separator !== undefined) { - this.thousandsSeparator.set(opts.number_format.thousands_separator); - } - if (opts.number_format.compact) this.compact.set(true); - } + // Legend + if (opts.legend) { + if (opts.legend.show !== undefined) this.legendShow.set(opts.legend.show); + if (opts.legend.position) this.legendPosition.set(opts.legend.position); + } - // Y-axis - if (opts.y_axis) { - if (opts.y_axis.title) this.yAxisTitle.set(opts.y_axis.title); - if (opts.y_axis.min !== undefined) this.yAxisMin.set(opts.y_axis.min); - if (opts.y_axis.max !== undefined) this.yAxisMax.set(opts.y_axis.max); - if (opts.y_axis.begin_at_zero !== undefined) this.yAxisBeginAtZero.set(opts.y_axis.begin_at_zero); - if (opts.y_axis.scale_type) this.yAxisScaleType.set(opts.y_axis.scale_type); + // Units + if (opts.units) { + if (opts.units.convert_unit) { + this.unitMode.set('convert'); + this.convertUnit.set(opts.units.convert_unit); + } else if (opts.units.text) { + this.unitMode.set('custom'); + this.unitsText.set(opts.units.text); + if (opts.units.position) this.unitsPosition.set(opts.units.position); } + } - // X-axis - if (opts.x_axis) { - if (opts.x_axis.title) this.xAxisTitle.set(opts.x_axis.title); + // Number format + if (opts.number_format) { + if (opts.number_format.decimal_places !== undefined) { + this.decimalPlaces.set(opts.number_format.decimal_places); + } + if (opts.number_format.thousands_separator !== undefined) { + this.thousandsSeparator.set(opts.number_format.thousands_separator); } + if (opts.number_format.compact) this.compact.set(true); + } - // Sort & limit - if (opts.sort_by) this.sortBy.set(opts.sort_by); - if (opts.limit) this.limit.set(opts.limit); + // Y-axis + if (opts.y_axis) { + if (opts.y_axis.title) this.yAxisTitle.set(opts.y_axis.title); + if (opts.y_axis.min !== undefined) this.yAxisMin.set(opts.y_axis.min); + if (opts.y_axis.max !== undefined) this.yAxisMax.set(opts.y_axis.max); + if (opts.y_axis.begin_at_zero !== undefined) this.yAxisBeginAtZero.set(opts.y_axis.begin_at_zero); + if (opts.y_axis.scale_type) this.yAxisScaleType.set(opts.y_axis.scale_type); + } - // Color palette - if (opts.color_palette?.length) { - this.colorPalette.set([...opts.color_palette]); - } + // X-axis + if (opts.x_axis) { + if (opts.x_axis.title) this.xAxisTitle.set(opts.x_axis.title); } - this.codeModel.set({ language: 'sql', uri: 'query.sql', value: query.query_text }); - this.testQuery(); + // Sort & limit + if (opts.sort_by) this.sortBy.set(opts.sort_by); + if (opts.limit) this.limit.set(opts.limit); + + // Color palette + if (opts.color_palette?.length) { + this.colorPalette.set([...opts.color_palette]); + } } - }); + + this.codeModel.set({ language: 'sql', uri: 'query.sql', value: query.query_text }); + this.testQuery(); + } + } finally { + this.loading.set(false); + } } onCodeChange(value: string): void { this.queryText.set(value); } - testQuery(): void { + async testQuery(): Promise { if (!this.queryText().trim()) { return; } this.testing.set(true); this.showResults.set(false); - this._savedQueries - .testQuery(this.connectionId(), { query_text: this.queryText() }) - .pipe(finalize(() => this.testing.set(false))) - .subscribe((result: TestQueryResult) => { - if (result) { - this.testResults.set(result.data); - this.executionTime.set(result.execution_time_ms); - this.resultColumns.set(result.data.length > 0 ? Object.keys(result.data[0]) : []); - this.showResults.set(true); - - if (this.resultColumns().length > 0 && !this.labelColumn()) { - this.labelColumn.set(this.resultColumns()[0]); - } - // Auto-add first series if none exist - if (this.seriesList().length === 0 && this.resultColumns().length > 1) { - this.seriesList.set([{ value_column: this.resultColumns()[1] }]); - } + + try { + const result = await this._savedQueries.testQuery(this.connectionId(), { query_text: this.queryText() }); + + if (result) { + this.testResults.set(result.data); + this.executionTime.set(result.execution_time_ms); + this.resultColumns.set(result.data.length > 0 ? Object.keys(result.data[0]) : []); + this.showResults.set(true); + + if (this.resultColumns().length > 0 && !this.labelColumn()) { + this.labelColumn.set(this.resultColumns()[0]); + } + // Auto-add first series if none exist + if (this.seriesList().length === 0 && this.resultColumns().length > 1) { + this.seriesList.set([{ value_column: this.resultColumns()[1] }]); } - }); + } + } finally { + this.testing.set(false); + } this.angulartics2.eventTrack.next({ action: 'Charts: test query executed', @@ -573,7 +575,7 @@ export class ChartEditComponent implements OnInit { this.colorPalette.set([...DEFAULT_COLOR_PALETTE]); } - saveQuery(): void { + async saveQuery(): Promise { if (!this.queryName().trim() || !this.queryText().trim()) { return; } @@ -591,28 +593,24 @@ export class ChartEditComponent implements OnInit { widget_options: widgetOptions as unknown as Record, }; - if (this.isEditMode()) { - this._savedQueries - .updateSavedQuery(this.connectionId(), this.queryId(), payload) - .pipe(finalize(() => this.saving.set(false))) - .subscribe(() => { - this.router.navigate(['/panels', this.connectionId()]); + try { + if (this.isEditMode()) { + const result = await this._savedQueries.updateSavedQuery(this.connectionId(), this.queryId(), payload); + if (result) this.router.navigate(['/panels', this.connectionId()]); + this.angulartics2.eventTrack.next({ + action: 'Charts: saved query updated', }); - this.angulartics2.eventTrack.next({ - action: 'Charts: saved query updated', - }); - posthog.capture('Charts: saved query updated'); - } else { - this._savedQueries - .createSavedQuery(this.connectionId(), payload) - .pipe(finalize(() => this.saving.set(false))) - .subscribe(() => { - this.router.navigate(['/panels', this.connectionId()]); + posthog.capture('Charts: saved query updated'); + } else { + const result = await this._savedQueries.createSavedQuery(this.connectionId(), payload); + if (result) this.router.navigate(['/panels', this.connectionId()]); + this.angulartics2.eventTrack.next({ + action: 'Charts: saved query created', }); - this.angulartics2.eventTrack.next({ - action: 'Charts: saved query created', - }); - posthog.capture('Charts: saved query created'); + posthog.capture('Charts: saved query created'); + } + } finally { + this.saving.set(false); } } } diff --git a/frontend/src/app/components/dashboards/panel-renderers/dashboard-panel/dashboard-panel.component.ts b/frontend/src/app/components/dashboards/panel-renderers/dashboard-panel/dashboard-panel.component.ts index 3553d354d..6b80ffb3a 100644 --- a/frontend/src/app/components/dashboards/panel-renderers/dashboard-panel/dashboard-panel.component.ts +++ b/frontend/src/app/components/dashboards/panel-renderers/dashboard-panel/dashboard-panel.component.ts @@ -1,7 +1,6 @@ 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'; @@ -59,12 +58,14 @@ export class DashboardPanelComponent { 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._savedQueries.fetchSavedQuery(this.connectionId, this.widget.query_id), + this._savedQueries.executeSavedQuery(this.connectionId, this.widget.query_id), ]); this.savedQuery.set(query); - this.queryData.set(result.data); + if (result) { + this.queryData.set(result.data); + } this.loading.set(false); } catch (err: unknown) { const error = err as { error?: { message?: string } }; diff --git a/frontend/src/app/services/dashboards.service.ts b/frontend/src/app/services/dashboards.service.ts index 67a173896..0083ac490 100644 --- a/frontend/src/app/services/dashboards.service.ts +++ b/frontend/src/app/services/dashboards.service.ts @@ -1,6 +1,5 @@ -import { HttpClient } from '@angular/common/http'; -import { computed, Injectable, inject, ResourceRef, resource, signal } from '@angular/core'; -import { firstValueFrom } from 'rxjs'; +import { HttpResourceRef } from '@angular/common/http'; +import { computed, Injectable, inject, signal } from '@angular/core'; import { CreateDashboardPayload, CreateWidgetPayload, @@ -9,7 +8,7 @@ import { UpdateDashboardPayload, UpdateWidgetPayload, } from '../models/dashboard'; -import { NotificationsService } from './notifications.service'; +import { ApiService } from './api.service'; export type DashboardUpdateEvent = 'created' | 'updated' | 'deleted' | ''; @@ -17,8 +16,7 @@ export type DashboardUpdateEvent = 'created' | 'updated' | 'deleted' | ''; providedIn: 'root', }) export class DashboardsService { - private _http = inject(HttpClient); - private _notifications = inject(NotificationsService); + private _api = inject(ApiService); private _dashboardsUpdated = signal(''); public readonly dashboardsUpdated = this._dashboardsUpdated.asReadonly(); @@ -29,39 +27,26 @@ export class DashboardsService { // 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 dashboards list + private _dashboardsResource: HttpResourceRef = this._api.resource( + () => { + const connectionId = this._activeConnectionId(); + if (!connectionId) return undefined; + return `/dashboards/${connectionId}`; }, - }); + { errorMessage: 'Failed to fetch dashboards' }, + ); // 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; - } + private _dashboardResource: HttpResourceRef = this._api.resource( + () => { + const connectionId = this._activeConnectionId(); + const dashboardId = this._activeDashboardId(); + if (!connectionId || !dashboardId) return undefined; + return `/dashboard/${dashboardId}/${connectionId}`; }, - }); + { errorMessage: 'Failed to fetch dashboard' }, + ); // Computed signals for convenient access public readonly dashboards = computed(() => this._dashboardsResource.value() ?? []); @@ -93,19 +78,13 @@ export class DashboardsService { this._dashboardResource.reload(); } - // Dashboard CRUD operations (Promise-based) + // Dashboard CRUD operations 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; - } + const dashboard = await this._api.post(`/dashboards/${connectionId}`, payload, { + successMessage: 'Dashboard created successfully', + }); + if (dashboard) this._dashboardsUpdated.set('created'); + return dashboard; } async updateDashboard( @@ -113,54 +92,32 @@ export class DashboardsService { 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; - } + const dashboard = await this._api.put(`/dashboard/${dashboardId}/${connectionId}`, payload, { + successMessage: 'Dashboard updated successfully', + }); + if (dashboard) this._dashboardsUpdated.set('updated'); + return dashboard; } 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; - } + const dashboard = await this._api.delete(`/dashboard/${dashboardId}/${connectionId}`, { + successMessage: 'Dashboard deleted successfully', + }); + if (dashboard) this._dashboardsUpdated.set('deleted'); + return dashboard; } - // Widget CRUD operations (Promise-based) + // Widget CRUD operations 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'); - this._dashboardsUpdated.set('updated'); - 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; - } + const widget = await this._api.post(`/dashboard/${dashboardId}/widget/${connectionId}`, payload, { + successMessage: 'Widget created successfully', + }); + if (widget) this._dashboardsUpdated.set('updated'); + return widget; } async updateWidget( @@ -169,17 +126,7 @@ export class DashboardsService { 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; - } + return this._api.put(`/dashboard/${dashboardId}/widget/${widgetId}/${connectionId}`, payload); } async updateWidgetPosition( @@ -188,32 +135,17 @@ export class DashboardsService { 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; - } + return this._api.put(`/dashboard/${dashboardId}/widget/${widgetId}/${connectionId}`, payload); } 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'); - this._dashboardsUpdated.set('updated'); - 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; - } + const widget = await this._api.delete( + `/dashboard/${dashboardId}/widget/${widgetId}/${connectionId}`, + { + successMessage: 'Widget deleted successfully', + }, + ); + if (widget) this._dashboardsUpdated.set('updated'); + return widget; } } diff --git a/frontend/src/app/services/saved-queries.service.ts b/frontend/src/app/services/saved-queries.service.ts index 10969d9a3..d6782ead4 100644 --- a/frontend/src/app/services/saved-queries.service.ts +++ b/frontend/src/app/services/saved-queries.service.ts @@ -1,8 +1,5 @@ -import { HttpClient } from '@angular/common/http'; +import { HttpResourceRef } from '@angular/common/http'; import { computed, Injectable, inject, signal } from '@angular/core'; -import { rxResource } from '@angular/core/rxjs-interop'; -import { EMPTY, Observable } from 'rxjs'; -import { catchError, tap } from 'rxjs/operators'; import { CreateSavedQueryPayload, QueryExecutionResult, @@ -11,7 +8,7 @@ import { TestQueryResult, UpdateSavedQueryPayload, } from '../models/saved-query'; -import { NotificationsService } from './notifications.service'; +import { ApiService } from './api.service'; export type QueryUpdateEvent = 'created' | 'updated' | 'deleted' | ''; @@ -19,8 +16,7 @@ export type QueryUpdateEvent = 'created' | 'updated' | 'deleted' | ''; providedIn: 'root', }) export class SavedQueriesService { - private _http = inject(HttpClient); - private _notifications = inject(NotificationsService); + private _api = inject(ApiService); private _queriesUpdated = signal(''); public readonly queriesUpdated = this._queriesUpdated.asReadonly(); @@ -29,19 +25,14 @@ export class SavedQueriesService { private _activeConnectionId = signal(null); // Resource for saved queries - private _savedQueriesResource = rxResource({ - params: () => this._activeConnectionId(), - stream: ({ params: connectionId }) => { - if (!connectionId) return EMPTY; - return this._http.get(`/connection/${connectionId}/saved-queries`).pipe( - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || 'Failed to fetch saved queries'); - return EMPTY; - }), - ); + private _savedQueriesResource: HttpResourceRef = this._api.resource( + () => { + const connectionId = this._activeConnectionId(); + if (!connectionId) return undefined; + return `/connection/${connectionId}/saved-queries`; }, - }); + { errorMessage: 'Failed to fetch saved queries' }, + ); // Computed signals for convenient access public readonly savedQueries = computed(() => this._savedQueriesResource.value() ?? []); @@ -57,81 +48,51 @@ export class SavedQueriesService { this._savedQueriesResource.reload(); } - fetchSavedQuery(connectionId: string, queryId: string): Observable { - return this._http.get(`/connection/${connectionId}/saved-query/${queryId}`).pipe( - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || 'Failed to fetch saved query'); - return EMPTY; - }), - ); + async fetchSavedQuery(connectionId: string, queryId: string): Promise { + return this._api.get(`/connection/${connectionId}/saved-query/${queryId}`); } - createSavedQuery(connectionId: string, payload: CreateSavedQueryPayload): Observable { - return this._http.post(`/connection/${connectionId}/saved-query`, payload).pipe( - tap(() => { - this._notifications.showSuccessSnackbar('Saved query created successfully'); - this._queriesUpdated.set('created'); - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || 'Failed to create saved query'); - return EMPTY; - }), - ); + async createSavedQuery(connectionId: string, payload: CreateSavedQueryPayload): Promise { + const query = await this._api.post(`/connection/${connectionId}/saved-query`, payload, { + successMessage: 'Saved query created successfully', + }); + if (query) this._queriesUpdated.set('created'); + return query; } - updateSavedQuery(connectionId: string, queryId: string, payload: UpdateSavedQueryPayload): Observable { - return this._http.put(`/connection/${connectionId}/saved-query/${queryId}`, payload).pipe( - tap(() => { - this._notifications.showSuccessSnackbar('Saved query updated successfully'); - this._queriesUpdated.set('updated'); - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || 'Failed to update saved query'); - return EMPTY; - }), - ); + async updateSavedQuery( + connectionId: string, + queryId: string, + payload: UpdateSavedQueryPayload, + ): Promise { + const query = await this._api.put(`/connection/${connectionId}/saved-query/${queryId}`, payload, { + successMessage: 'Saved query updated successfully', + }); + if (query) this._queriesUpdated.set('updated'); + return query; } - deleteSavedQuery(connectionId: string, queryId: string): Observable { - return this._http.delete(`/connection/${connectionId}/saved-query/${queryId}`).pipe( - tap(() => { - this._notifications.showSuccessSnackbar('Saved query deleted successfully'); - this._queriesUpdated.set('deleted'); - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || 'Failed to delete saved query'); - return EMPTY; - }), - ); + async deleteSavedQuery(connectionId: string, queryId: string): Promise { + const query = await this._api.delete(`/connection/${connectionId}/saved-query/${queryId}`, { + successMessage: 'Saved query deleted successfully', + }); + if (query) this._queriesUpdated.set('deleted'); + return query; } - executeSavedQuery(connectionId: string, queryId: string, tableName?: string): Observable { - const params: Record = {}; - if (tableName) { - params['tableName'] = tableName; - } - return this._http - .post(`/connection/${connectionId}/saved-query/${queryId}/execute`, {}, { params }) - .pipe( - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || 'Failed to execute query'); - return EMPTY; - }), - ); + async executeSavedQuery( + connectionId: string, + queryId: string, + tableName?: string, + ): Promise { + return this._api.post( + `/connection/${connectionId}/saved-query/${queryId}/execute`, + {}, + tableName ? { params: { tableName } } : undefined, + ); } - testQuery(connectionId: string, payload: TestQueryPayload): Observable { - return this._http.post(`/connection/${connectionId}/query/test`, payload).pipe( - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error?.message || 'Failed to test query'); - return EMPTY; - }), - ); + async testQuery(connectionId: string, payload: TestQueryPayload): Promise { + return this._api.post(`/connection/${connectionId}/query/test`, payload); } }