diff --git a/projects/assets-library/assets/images/loader-expandable-row.gif b/projects/assets-library/assets/images/loader-expandable-row.gif new file mode 100644 index 000000000..385eef56c Binary files /dev/null and b/projects/assets-library/assets/images/loader-expandable-row.gif differ diff --git a/projects/assets-library/assets/images/loader-page.gif b/projects/assets-library/assets/images/loader-page.gif new file mode 100644 index 000000000..eaa47e252 Binary files /dev/null and b/projects/assets-library/assets/images/loader-page.gif differ diff --git a/projects/assets-library/assets/images/loader-spinner.gif b/projects/assets-library/assets/images/loader-spinner.gif new file mode 100644 index 000000000..21c09945a Binary files /dev/null and b/projects/assets-library/assets/images/loader-spinner.gif differ diff --git a/projects/assets-library/src/images/image-type.ts b/projects/assets-library/src/images/image-type.ts new file mode 100644 index 000000000..15e024ebb --- /dev/null +++ b/projects/assets-library/src/images/image-type.ts @@ -0,0 +1,6 @@ +export const enum ImagesAssetPath { + ErrorPage = 'assets/images/error-page.svg', + LoaderSpinner = 'assets/images/loader-spinner.gif', + LoaderPage = 'assets/images/loader-page.gif', + LoaderExpandableRow = 'assets/images/loader-expandable-row.gif' +} diff --git a/projects/assets-library/src/public-api.ts b/projects/assets-library/src/public-api.ts index 1a6918a79..f42929a4f 100644 --- a/projects/assets-library/src/public-api.ts +++ b/projects/assets-library/src/public-api.ts @@ -6,3 +6,4 @@ export * from './icons/icon-type'; export * from './icons/icon-registry.service'; export * from './icons/icon-library.module'; export * from './icons/testing/icon-library-testing.module'; +export * from './images/image-type'; diff --git a/projects/components/src/load-async/load-async.directive.ts b/projects/components/src/load-async/load-async.directive.ts index 3feff95d0..ee50ef022 100644 --- a/projects/components/src/load-async/load-async.directive.ts +++ b/projects/components/src/load-async/load-async.directive.ts @@ -10,7 +10,7 @@ import { ViewContainerRef } from '@angular/core'; import { Observable, ReplaySubject } from 'rxjs'; -import { LoadAsyncContext, LoadAsyncService } from './load-async.service'; +import { LoadAsyncConfig, LoadAsyncContext, LoadAsyncService } from './load-async.service'; import { ASYNC_WRAPPER_PARAMETERS$, LoadAsyncWrapperComponent, @@ -23,6 +23,10 @@ import { export class LoadAsyncDirective implements OnChanges, OnDestroy { @Input('htLoadAsync') public data$?: Observable; + + // tslint:disable-next-line:no-input-rename + @Input('htLoadAsyncConfig') + public config?: LoadAsyncConfig; private readonly wrapperParamsSubject: ReplaySubject = new ReplaySubject(1); private readonly wrapperInjector: Injector; private wrapperView?: ComponentRef; @@ -49,7 +53,8 @@ export class LoadAsyncDirective implements OnChanges, OnDestroy { this.wrapperView = this.wrapperView || this.buildWrapperView(); this.wrapperParamsSubject.next({ state$: this.loadAsyncService.mapObservableState(this.data$), - content: this.templateRef + content: this.templateRef, + config: this.config }); } else { // If observable is cleared, clear the DOM diff --git a/projects/components/src/load-async/load-async.service.ts b/projects/components/src/load-async/load-async.service.ts index d10b23a8b..f745c9df1 100644 --- a/projects/components/src/load-async/load-async.service.ts +++ b/projects/components/src/load-async/load-async.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@angular/core'; +import { IconType } from '@hypertrace/assets-library'; import { CustomError } from '@hypertrace/common'; import { Observable, of } from 'rxjs'; import { catchError, defaultIfEmpty, map, startWith } from 'rxjs/operators'; @@ -50,18 +51,39 @@ export interface LoadAsyncContext { $implicit: unknown; } -export type AsyncState = ErrorAsyncState | SuccessAsyncState | LoadingAsyncState; +export interface LoadAsyncConfig { + load?: LoadingStateConfig; + noData?: NoDataOrErrorStateConfig; + error?: NoDataOrErrorStateConfig; +} + +export type AsyncState = LoadingAsyncState | SuccessAsyncState | NoDataOrErrorAsyncState; + +export const enum LoaderType { + Spinner = 'spinner', + ExpandableRow = 'expandable-row', + Page = 'page' +} interface LoadingAsyncState { type: LoadAsyncStateType.Loading; } - interface SuccessAsyncState { type: LoadAsyncStateType.Success; context: LoadAsyncContext; } -export interface ErrorAsyncState { +interface NoDataOrErrorAsyncState { type: LoadAsyncStateType.GenericError | LoadAsyncStateType.NoData; description?: string; } + +interface LoadingStateConfig { + loaderType?: LoaderType; +} + +interface NoDataOrErrorStateConfig { + icon?: IconType; + title?: string; + description?: string; +} diff --git a/projects/components/src/load-async/loader/loader.component.scss b/projects/components/src/load-async/loader/loader.component.scss index c09c207c2..f9c88ac07 100644 --- a/projects/components/src/load-async/loader/loader.component.scss +++ b/projects/components/src/load-async/loader/loader.component.scss @@ -7,4 +7,19 @@ flex-direction: column; justify-content: center; align-items: center; + + .page { + height: 50px; + width: 50px; + } + + .spinner { + height: 20px; + width: 20px; + } + + .expandable-row { + height: 20px; + width: auto; + } } diff --git a/projects/components/src/load-async/loader/loader.component.test.ts b/projects/components/src/load-async/loader/loader.component.test.ts new file mode 100644 index 000000000..5a22da4ea --- /dev/null +++ b/projects/components/src/load-async/loader/loader.component.test.ts @@ -0,0 +1,50 @@ +import { CommonModule } from '@angular/common'; +import { ImagesAssetPath } from '@hypertrace/assets-library'; +import { createHostFactory, SpectatorHost } from '@ngneat/spectator/jest'; +import { LoaderType } from '../load-async.service'; +import { LoaderComponent } from './loader.component'; + +describe('Loader component', () => { + let spectator: SpectatorHost; + + const createHost = createHostFactory({ + component: LoaderComponent, + imports: [CommonModule] + }); + + test('Loader component when loader type is page', () => { + spectator = createHost(``); + + expect(spectator.query('.ht-loader')).toExist(); + expect(spectator.query('.ht-loader img')).toExist(); + expect(spectator.query('.ht-loader img')).toHaveClass(LoaderType.Page); + expect(spectator.query('.ht-loader img')).toHaveAttribute('src', ImagesAssetPath.LoaderPage); + }); + + test('Loader component when loader type is not passed', () => { + spectator = createHost(``); + + expect(spectator.query('.ht-loader')).toExist(); + expect(spectator.query('.ht-loader img')).toExist(); + expect(spectator.query('.ht-loader img')).toHaveClass(LoaderType.Spinner); + expect(spectator.query('.ht-loader img')).toHaveAttribute('src', ImagesAssetPath.LoaderSpinner); + }); + + test('Loader component when loader type is spinner', () => { + spectator = createHost(``); + + expect(spectator.query('.ht-loader')).toExist(); + expect(spectator.query('.ht-loader img')).toExist(); + expect(spectator.query('.ht-loader img')).toHaveClass(LoaderType.Spinner); + expect(spectator.query('.ht-loader img')).toHaveAttribute('src', ImagesAssetPath.LoaderSpinner); + }); + + test('Loader component loader type is expandable row', () => { + spectator = createHost(``); + + expect(spectator.query('.ht-loader')).toExist(); + expect(spectator.query('.ht-loader img')).toExist(); + expect(spectator.query('.ht-loader img')).toHaveClass(LoaderType.ExpandableRow); + expect(spectator.query('.ht-loader img')).toHaveAttribute('src', ImagesAssetPath.LoaderExpandableRow); + }); +}); diff --git a/projects/components/src/load-async/loader/loader.component.ts b/projects/components/src/load-async/loader/loader.component.ts index 6834f7f3d..04f697ec0 100644 --- a/projects/components/src/load-async/loader/loader.component.ts +++ b/projects/components/src/load-async/loader/loader.component.ts @@ -1,15 +1,36 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { IconType } from '@hypertrace/assets-library'; -import { IconSize } from '../../icon/icon-size'; +import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core'; +import { ImagesAssetPath } from '@hypertrace/assets-library'; +import { LoaderType } from '../load-async.service'; @Component({ selector: 'ht-loader', styleUrls: ['./loader.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, template: `
- +
- `, - changeDetection: ChangeDetectionStrategy.OnPush + ` }) -export class LoaderComponent {} +export class LoaderComponent implements OnChanges { + @Input() + public loaderType?: LoaderType; + + public currentLoaderType: LoaderType = LoaderType.Spinner; + + public ngOnChanges(): void { + this.currentLoaderType = this.loaderType ?? LoaderType.Spinner; + } + + public getImagePathFromType(loaderType: LoaderType): ImagesAssetPath { + switch (loaderType) { + case LoaderType.ExpandableRow: + return ImagesAssetPath.LoaderExpandableRow; + case LoaderType.Page: + return ImagesAssetPath.LoaderPage; + case LoaderType.Spinner: + default: + return ImagesAssetPath.LoaderSpinner; + } + } +} diff --git a/projects/components/src/load-async/wrapper/load-async-wrapper.component.ts b/projects/components/src/load-async/wrapper/load-async-wrapper.component.ts index 8fe07ed85..13c3b268c 100644 --- a/projects/components/src/load-async/wrapper/load-async-wrapper.component.ts +++ b/projects/components/src/load-async/wrapper/load-async-wrapper.component.ts @@ -3,7 +3,7 @@ import { IconType } from '@hypertrace/assets-library'; import { Observable } from 'rxjs'; import { switchMap, tap } from 'rxjs/operators'; import { LoadAsyncStateType } from '../load-async-state.type'; -import { AsyncState, ErrorAsyncState, LoadAsyncContext } from '../load-async.service'; +import { AsyncState, LoadAsyncConfig, LoadAsyncContext, LoaderType } from '../load-async.service'; export const ASYNC_WRAPPER_PARAMETERS$ = new InjectionToken>( 'ASYNC_WRAPPER_PARAMETERS$' @@ -14,7 +14,7 @@ export const ASYNC_WRAPPER_PARAMETERS$ = new InjectionToken - + @@ -34,31 +34,43 @@ export class LoadAsyncWrapperComponent { public readonly state$: Observable; public icon?: IconType; + public loaderType?: LoaderType; public title?: string; public description: string = ''; public content?: TemplateRef; + public config?: LoadAsyncConfig; public constructor(@Inject(ASYNC_WRAPPER_PARAMETERS$) parameters$: Observable) { this.state$ = parameters$.pipe( - tap(params => (this.content = params.content)), + tap(params => { + this.content = params.content; + this.config = params.config; + }), switchMap(parameter => parameter.state$), - tap(state => this.updateMessage(state.type, (state as Partial).description)) + tap(state => this.updateMessage(state)) ); } - private updateMessage(stateType: LoadAsyncStateType, description: string = ''): void { - this.description = description; - - switch (stateType) { + private updateMessage(state: AsyncState): void { + switch (state.type) { + case LoadAsyncStateType.Loading: + this.loaderType = this.config?.load?.loaderType; + break; case LoadAsyncStateType.NoData: - this.icon = IconType.NoData; - this.title = 'No Data'; + this.icon = this.config?.noData?.icon ?? IconType.NoData; + this.title = this.config?.noData?.title ?? 'No Data'; + this.description = this.config?.noData?.description ?? ''; break; case LoadAsyncStateType.GenericError: + this.icon = this.config?.error?.icon ?? IconType.Error; + this.title = this.config?.error?.title ?? 'Error'; + this.description = state.description ?? this.config?.error?.description ?? ''; + break; default: - this.icon = IconType.Error; - this.title = 'Error'; + this.icon = undefined; + this.title = ''; + this.description = ''; } } } @@ -66,4 +78,5 @@ export class LoadAsyncWrapperComponent { export interface LoadAsyncWrapperParameters { state$: Observable; content: TemplateRef; + config?: LoadAsyncConfig; } diff --git a/projects/components/src/not-found/not-found.component.ts b/projects/components/src/not-found/not-found.component.ts index b5963a383..4929b389b 100644 --- a/projects/components/src/not-found/not-found.component.ts +++ b/projects/components/src/not-found/not-found.component.ts @@ -1,4 +1,5 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ImagesAssetPath } from '@hypertrace/assets-library'; import { NavigationService } from '@hypertrace/common'; import { ButtonRole, ButtonStyle } from '../button/button'; @@ -9,7 +10,7 @@ import { ButtonRole, ButtonStyle } from '../button/button'; template: `
- not found page + not found page
Page not found
diff --git a/projects/components/src/public-api.ts b/projects/components/src/public-api.ts index a8bd27c84..dbb069ead 100644 --- a/projects/components/src/public-api.ts +++ b/projects/components/src/public-api.ts @@ -171,8 +171,10 @@ export * from './list-view/list-view.component'; export * from './list-view/list-view.module'; // Load Async -export { LoadAsyncDirective } from './load-async/load-async.directive'; -export { LoadAsyncModule } from './load-async/load-async.module'; +export * from './load-async/load-async.directive'; +export * from './load-async/load-async.module'; +export * from './load-async/load-async.service'; +export * from './load-async/load-async-state.type'; // Message Display export { MessageDisplayComponent } from './message-display/message-display.component'; diff --git a/projects/components/src/table/table.component.ts b/projects/components/src/table/table.component.ts index 46b164bba..028b0acfa 100644 --- a/projects/components/src/table/table.component.ts +++ b/projects/components/src/table/table.component.ts @@ -27,6 +27,7 @@ import { without } from 'lodash-es'; import { BehaviorSubject, combineLatest, merge, Observable, Subject } from 'rxjs'; import { filter, map } from 'rxjs/operators'; import { FilterAttribute } from '../filtering/filter/filter-attribute'; +import { LoadAsyncConfig } from '../load-async/load-async.service'; import { PageEvent } from '../paginator/page.event'; import { PaginatorComponent } from '../paginator/paginator.component'; import { CoreTableCellRendererType } from './cells/types/core-table-cell-renderer-type'; @@ -162,7 +163,10 @@ import { TableColumnConfigExtended, TableService } from './table.service';
- +
@@ -272,6 +276,9 @@ export class TableComponent @Input() public pageSize?: number = 50; + @Input() + public loadingConfig?: LoadAsyncConfig; + @Output() public readonly rowClicked: EventEmitter = new EventEmitter(); diff --git a/projects/observability/src/shared/dashboard/widgets/table/table-widget-base.model.ts b/projects/observability/src/shared/dashboard/widgets/table/table-widget-base.model.ts index 8fadc04c7..25cda6b41 100644 --- a/projects/observability/src/shared/dashboard/widgets/table/table-widget-base.model.ts +++ b/projects/observability/src/shared/dashboard/widgets/table/table-widget-base.model.ts @@ -1,4 +1,11 @@ -import { TableDataSource, TableMode, TableRow, TableSelectionMode, TableStyle } from '@hypertrace/components'; +import { + LoadAsyncConfig, + TableDataSource, + TableMode, + TableRow, + TableSelectionMode, + TableStyle +} from '@hypertrace/components'; import { ArrayPropertyTypeInstance, BaseModel, @@ -13,6 +20,7 @@ import { ModelModelPropertyTypeInstance, ModelProperty, ModelPropertyType, + PLAIN_OBJECT_PROPERTY, STRING_PROPERTY } from '@hypertrace/hyperdash'; import { ModelInject, MODEL_API } from '@hypertrace/hyperdash-angular'; @@ -118,6 +126,13 @@ export abstract class TableWidgetBaseModel extends BaseModel { }) public resizable: boolean = true; + @ModelProperty({ + key: 'loadingConfig', + required: false, + type: PLAIN_OBJECT_PROPERTY.type + }) + public loadingConfig?: LoadAsyncConfig; + @ModelInject(MODEL_API) protected readonly api!: ModelApi; diff --git a/projects/observability/src/shared/dashboard/widgets/table/table-widget-renderer.component.ts b/projects/observability/src/shared/dashboard/widgets/table/table-widget-renderer.component.ts index 7202d1b3c..6dee29931 100644 --- a/projects/observability/src/shared/dashboard/widgets/table/table-widget-renderer.component.ts +++ b/projects/observability/src/shared/dashboard/widgets/table/table-widget-renderer.component.ts @@ -84,6 +84,7 @@ import { TableWidgetModel } from './table-widget.model'; [selectionMode]="this.model.getSelectionMode()" [display]="this.model.style" [data]="this.data$ | async" + [loadingConfig]="this.model.loadingConfig" [filters]="this.combinedFilters$ | async" [queryProperties]="this.queryProperties$ | async" [pageable]="this.api.model.isPageable()"