Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NAS-128169 / 24.10 / Implement main logic in DashboardStore #10009

Merged
merged 4 commits into from
May 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
mat-button
class="add-button"
ixTest="add-group"
[disabled]="isLoading()"
(click)="onAddGroup()"
>
{{ 'Add' | translate }}
Expand All @@ -23,13 +24,15 @@
mat-button
color="primary"
ixTest="save"
[disabled]="isLoading()"
(click)="onSave()"
>
{{ 'Save' | translate }}
</button>
<button
mat-button
ixTest="cancel"
[disabled]="isLoading()"
(click)="onCancelConfigure()"
>
{{ 'Cancel' | translate }}
Expand Down Expand Up @@ -74,6 +77,6 @@
</ng-container>

<ng-template #noWidgets>

<ix-empty [conf]="emptyDashboardConf"></ix-empty>
</ng-template>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ import {
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { TranslateService } from '@ngx-translate/core';
import { EmptyType } from 'app/enums/empty-type.enum';
import { EmptyConfig } from 'app/interfaces/empty-config.interface';
import { WidgetGroupFormComponent } from 'app/pages/dashboard/components/widget-group-form/widget-group-form.component';
import { DashboardStore } from 'app/pages/dashboard/services/dashboard.store';
import { WidgetGroup, WidgetGroupLayout } from 'app/pages/dashboard/types/widget-group.interface';
import { ErrorHandlerService } from 'app/services/error-handler.service';
import { IxChainedSlideInService } from 'app/services/ix-chained-slide-in.service';

@UntilDestroy()
Expand Down Expand Up @@ -40,9 +44,17 @@ export class DashboardComponent implements OnInit {
// TODO: Prevent user from entering configuration mode while loading.
readonly isLoading = toSignal(this.dashboardStore.isLoading$);

emptyDashboardConf: EmptyConfig = {
type: EmptyType.NoPageData,
large: true,
title: this.translate.instant('Dashboard is Empty!'),
};

constructor(
private dashboardStore: DashboardStore,
private slideIn: IxChainedSlideInService,
private errorHandler: ErrorHandlerService,
private translate: TranslateService,
) {}

ngOnInit(): void {
Expand Down Expand Up @@ -99,18 +111,13 @@ export class DashboardComponent implements OnInit {

// TODO: Filter out fully empty groups somewhere.
protected onSave(): void {
this.dashboardStore
.save(this.renderedGroups())
.pipe(untilDestroyed(this))
.subscribe(() => {
this.isEditing.set(false);
// TODO: Handle errors.
});
this.dashboardStore.save(this.renderedGroups())
.pipe(this.errorHandler.catchError(), untilDestroyed(this))
.subscribe(() => this.isEditing.set(false));
}

private loadGroups(): void {
this.dashboardStore
.groups$
this.dashboardStore.groups$
.pipe(untilDestroyed(this))
.subscribe((groups) => {
if (this.isEditing()) {
Expand Down
30 changes: 16 additions & 14 deletions src/app/pages/dashboard/dashboard.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { TranslateModule } from '@ngx-translate/core';
import { BaseChartDirective } from 'ng2-charts';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { CommonDirectivesModule } from 'app/directives/common/common-directives.module';
import { EmptyComponent } from 'app/modules/empty/empty.component';
import { IxDropGridModule } from 'app/modules/ix-drop-grid/ix-drop-grid.module';
import { IxFormsModule } from 'app/modules/ix-forms/ix-forms.module';
import { IxIconModule } from 'app/modules/ix-icon/ix-icon.module';
Expand All @@ -34,6 +35,20 @@ import { widgetComponents } from 'app/pages/dashboard/widgets/all-widgets.consta
import { WidgetDatapointComponent } from 'app/pages/dashboard/widgets/common/widget-datapoint/widget-datapoint.component';

@NgModule({
declarations: [
DashboardComponent,
WidgetGroupComponent,
WidgetErrorComponent,
WidgetGroupFormComponent,
WidgetEditorGroupComponent,
WidgetDatapointComponent,
WidgetGroupControlsComponent,
...widgetComponents,
],
providers: [
DashboardStore,
WidgetResourcesService,
],
imports: [
IxFormsModule,
ReactiveFormsModule,
Expand Down Expand Up @@ -66,20 +81,7 @@ import { WidgetDatapointComponent } from 'app/pages/dashboard/widgets/common/wid
},
}),
IxDropGridModule,
],
declarations: [
DashboardComponent,
WidgetGroupComponent,
WidgetErrorComponent,
WidgetGroupFormComponent,
WidgetEditorGroupComponent,
WidgetDatapointComponent,
WidgetGroupControlsComponent,
...widgetComponents,
],
providers: [
DashboardStore,
WidgetResourcesService,
EmptyComponent,
],
})
export class DashboardModule {
Expand Down
101 changes: 101 additions & 0 deletions src/app/pages/dashboard/services/dashboard.store.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { createServiceFactory, SpectatorService, mockProvider } from '@ngneat/spectator/jest';
import { firstValueFrom, of } from 'rxjs';
import { mockCall, mockWebSocket } from 'app/core/testing/utils/mock-websocket.utils';
import { WidgetGroupLayout } from 'app/pages/dashboard/types/widget-group.interface';
import { WidgetType } from 'app/pages/dashboard/types/widget.interface';
import { AuthService } from 'app/services/auth/auth.service';
import { WebSocketService } from 'app/services/ws.service';
import { DashboardStore, initialState } from './dashboard.store';

const initialGroups = [
{
name: 'Help',
rendered: true,
},
{
layout: WidgetGroupLayout.Halves,
slots: [
{ type: WidgetType.Memory },
{ type: WidgetType.InterfaceIp },
],
},
];

describe('DashboardStore', () => {
let spectator: SpectatorService<DashboardStore>;
const createService = createServiceFactory({
service: DashboardStore,
providers: [
mockProvider(AuthService, {
user$: of({
attributes: {
dashState: initialGroups,
},
}),
}),
mockWebSocket([
mockCall('auth.set_attribute'),
]),
],
});

beforeEach(() => {
spectator = createService();
});

it('correctly initializes dashboard store and converts old dashboard widgets to new ones', async () => {
expect(await firstValueFrom(spectator.service.state$)).toMatchObject({
...initialState,
});

spectator.service.entered();

expect(await firstValueFrom(spectator.service.state$)).toMatchObject({
...initialState,
groups: [
{
layout: WidgetGroupLayout.Full,
slots: [
{ type: WidgetType.Help },
],
},
{
layout: WidgetGroupLayout.Halves,
slots: [
{ type: WidgetType.Memory },
{ type: WidgetType.InterfaceIp },
],
},
],
});
});

it('should handle save operation and its completion', () => {
const finalizeSpy = jest.spyOn(spectator.service, 'toggleLoadingState');

spectator.service.save([{
layout: WidgetGroupLayout.Full,
slots: [
{ type: WidgetType.Hostname },
],
},
]).subscribe();

const websocket = spectator.inject(WebSocketService);
expect(websocket.call).toHaveBeenCalledWith('auth.set_attribute', [
'dashState',
[{
layout: WidgetGroupLayout.Full,
slots: [
{ type: WidgetType.Hostname },
],
}],
]);

expect(finalizeSpy).toHaveBeenCalledWith(false);
});

afterEach(() => {
jest.clearAllMocks();
});
});
122 changes: 105 additions & 17 deletions src/app/pages/dashboard/services/dashboard.store.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
import { Injectable } from '@angular/core';
import { UntilDestroy } from '@ngneat/until-destroy';
import { ComponentStore } from '@ngrx/component-store';
import { Observable, of, tap } from 'rxjs';
import { demoWidgets } from 'app/pages/dashboard/services/demo-widgets.constant';
import { WidgetGroup } from 'app/pages/dashboard/types/widget-group.interface';
import {
EMPTY,
Observable, catchError, filter, finalize, map, switchMap, tap,
} from 'rxjs';
import { WidgetName } from 'app/enums/widget-name.enum';
import { WidgetGroup, WidgetGroupLayout } from 'app/pages/dashboard/types/widget-group.interface';
import { SomeWidgetSettings, WidgetType } from 'app/pages/dashboard/types/widget.interface';
import { AuthService } from 'app/services/auth/auth.service';
import { ErrorHandlerService } from 'app/services/error-handler.service';
import { WebSocketService } from 'app/services/ws.service';

// we have external `DashConfigItem` in old dashboard, but it will be removed once we go ahead with new dashboard
export interface OldDashboardConfigItem {
name: WidgetName;
identifier?: string;
}

export interface DashboardState {
isLoading: boolean;
Expand All @@ -15,6 +28,12 @@ export interface DashboardState {
groups: WidgetGroup[];
}

export const initialState: DashboardState = {
isLoading: false,
globalError: '',
groups: [],
};

/**
* This store ONLY manages loading and saving of the dashboard to/from user settings.
* It does not know or care about data used inside the widgets.
Expand All @@ -26,28 +45,97 @@ export class DashboardStore extends ComponentStore<DashboardState> {
readonly isLoading$ = this.select((state) => state.isLoading);
readonly groups$ = this.select((state) => state.groups);

constructor(
private authService: AuthService,
private ws: WebSocketService,
private errorHandler: ErrorHandlerService,
) {
super(initialState);
}

readonly entered = this.effect((trigger$) => {
// TODO: Load data from user attributes.
// TODO: Set isLoading flag, but do not clear previous state.
// TODO: If it's in old format, migrate to new format.
// TODO: Handle global errors like something network errors or something completely unexpected in user attributes.
return trigger$.pipe(
tap(() => this.setDemoData()),
tap(() => this.toggleLoadingState(true)),
switchMap(() => this.authService.user$.pipe(
filter(Boolean),
map((user) => user.attributes.dashState),
)),
tap((dashState) => {
this.setState({
isLoading: false,
globalError: '',
groups: this.getDashboardGroups(dashState || []),
});
}),
catchError((error) => {
this.handleError(error);
return EMPTY;
}),
);
});

// eslint-disable-next-line unused-imports/no-unused-vars
save(groups: WidgetGroup[]): Observable<void> {
// TODO: Save to user settings.
return of(undefined);
this.toggleLoadingState(true);

return this.ws.call('auth.set_attribute', ['dashState', groups]).pipe(
finalize(() => this.toggleLoadingState(false)),
);
}

// TODO: Demo code.
private setDemoData(): void {
this.setState({
isLoading: false,
globalError: '',
groups: demoWidgets,
handleError(error: unknown): void {
this.errorHandler.showErrorModal(error);
this.toggleLoadingState(false);
}

toggleLoadingState(isLoading: boolean): void {
this.patchState((state) => ({ ...state, isLoading }));
}

private getDashboardGroups(dashState: WidgetGroup[] | OldDashboardConfigItem[]): WidgetGroup[] {
return dashState.map((widget) => {
if (!widget.hasOwnProperty('layout')) {
const oldDashboardWidget = widget as unknown as OldDashboardConfigItem;
return {
layout: WidgetGroupLayout.Full,
slots: [{
type: this.getWidgetTypeFromOldDashboard(oldDashboardWidget.name),
settings: this.extractSettings(oldDashboardWidget),
}],
};
}

return widget as WidgetGroup;
});
}

private getWidgetTypeFromOldDashboard(name: WidgetName): WidgetType {
const unknownWidgetType = name as unknown as WidgetType;

// TODO: we have some widgets that are not yet implemented for the new dashboard
switch (name) {
case WidgetName.Help: return WidgetType.Help;
case WidgetName.Memory: return WidgetType.Memory;
case WidgetName.Interface: return WidgetType.InterfaceIp;
case WidgetName.Backup: return unknownWidgetType;
case WidgetName.Network: return unknownWidgetType;
case WidgetName.SystemInformation: return unknownWidgetType;
case WidgetName.Cpu: return unknownWidgetType;
case WidgetName.Storage: return unknownWidgetType;
case WidgetName.Pool: return unknownWidgetType;
default: return unknownWidgetType;
}
}

private extractSettings(widget: OldDashboardConfigItem): SomeWidgetSettings {
if (widget.identifier) {
const [key, value] = widget.identifier.split(',');

if (widget.name === WidgetName.Interface) {
return { interface: value };
}

return { [key]: value };
}
return {};
}
}