diff --git a/projects/components/src/load-async/load-async.module.ts b/projects/components/src/load-async/load-async.module.ts index 11a9b3e76..5c1ecea58 100644 --- a/projects/components/src/load-async/load-async.module.ts +++ b/projects/components/src/load-async/load-async.module.ts @@ -2,13 +2,14 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { IconModule } from '../icon/icon.module'; import { MessageDisplayModule } from '../message-display/message-display.module'; +import { SkeletonModule } from '../skeleton/skeleton.module'; import { LoadAsyncDirective } from './load-async.directive'; import { LoaderComponent } from './loader/loader.component'; import { LoadAsyncWrapperComponent } from './wrapper/load-async-wrapper.component'; @NgModule({ declarations: [LoadAsyncDirective, LoadAsyncWrapperComponent, LoaderComponent], - imports: [CommonModule, IconModule, MessageDisplayModule], + imports: [CommonModule, IconModule, MessageDisplayModule, SkeletonModule], exports: [LoadAsyncDirective] }) export class LoadAsyncModule {} diff --git a/projects/components/src/load-async/load-async.service.ts b/projects/components/src/load-async/load-async.service.ts index f745c9df1..11074ccec 100644 --- a/projects/components/src/load-async/load-async.service.ts +++ b/projects/components/src/load-async/load-async.service.ts @@ -62,7 +62,14 @@ export type AsyncState = LoadingAsyncState | SuccessAsyncState | NoDataOrErrorAs export const enum LoaderType { Spinner = 'spinner', ExpandableRow = 'expandable-row', - Page = 'page' + Page = 'page', + Rectangle = 'rectangle', + Text = 'text', + Square = 'square', + Circle = 'circle', + TableRow = 'table-row', + ListItem = 'list-item', + Donut = 'donut' } interface LoadingAsyncState { diff --git a/projects/components/src/load-async/loader/loader.component.scss b/projects/components/src/load-async/loader/loader.component.scss index f9c88ac07..5317ef04f 100644 --- a/projects/components/src/load-async/loader/loader.component.scss +++ b/projects/components/src/load-async/loader/loader.component.scss @@ -3,10 +3,6 @@ .ht-loader { width: 100%; height: 100%; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; .page { height: 50px; @@ -23,3 +19,10 @@ width: auto; } } + +.flex-centered { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} diff --git a/projects/components/src/load-async/loader/loader.component.test.ts b/projects/components/src/load-async/loader/loader.component.test.ts index 5a22da4ea..a0da5e322 100644 --- a/projects/components/src/load-async/loader/loader.component.test.ts +++ b/projects/components/src/load-async/loader/loader.component.test.ts @@ -1,6 +1,8 @@ import { CommonModule } from '@angular/common'; import { ImagesAssetPath } from '@hypertrace/assets-library'; import { createHostFactory, SpectatorHost } from '@ngneat/spectator/jest'; +import { MockComponent } from 'ng-mocks'; +import { SkeletonComponent, SkeletonType } from '../../skeleton/skeleton.component'; import { LoaderType } from '../load-async.service'; import { LoaderComponent } from './loader.component'; @@ -9,6 +11,7 @@ describe('Loader component', () => { const createHost = createHostFactory({ component: LoaderComponent, + declarations: [MockComponent(SkeletonComponent)], imports: [CommonModule] }); @@ -16,6 +19,7 @@ describe('Loader component', () => { spectator = createHost(``); expect(spectator.query('.ht-loader')).toExist(); + expect(spectator.query('.ht-loader')).toHaveClass('flex-centered'); 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); @@ -25,6 +29,7 @@ describe('Loader component', () => { spectator = createHost(``); expect(spectator.query('.ht-loader')).toExist(); + expect(spectator.query('.ht-loader')).toHaveClass('flex-centered'); 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); @@ -34,6 +39,7 @@ describe('Loader component', () => { spectator = createHost(``); expect(spectator.query('.ht-loader')).toExist(); + expect(spectator.query('.ht-loader')).toHaveClass('flex-centered'); 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); @@ -43,8 +49,93 @@ describe('Loader component', () => { spectator = createHost(``); expect(spectator.query('.ht-loader')).toExist(); + expect(spectator.query('.ht-loader')).toHaveClass('flex-centered'); 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); }); + + test('Should use old loader type by default', () => { + spectator = createHost(``); + + expect(spectator.component.isOldLoaderType).toBe(true); + expect(spectator.query(SkeletonComponent)).not.toExist(); + }); + + test('Should use corresponding skeleton component for loader type rectangle', () => { + spectator = createHost(``); + + expect(spectator.query('.ht-loader')).toExist(); + expect(spectator.query('.ht-loader')).not.toHaveClass('flex-centered'); + + const skeletonComponent = spectator.query(SkeletonComponent); + expect(skeletonComponent).toExist(); + expect(skeletonComponent).toHaveAttribute('skeletonType', SkeletonType.Rectangle); + }); + + test('Should use corresponding skeleton component for loader type rectangle text', () => { + spectator = createHost(``); + + expect(spectator.query('.ht-loader')).toExist(); + expect(spectator.query('.ht-loader')).not.toHaveClass('flex-centered'); + + const skeletonComponent = spectator.query(SkeletonComponent); + expect(skeletonComponent).toExist(); + expect(skeletonComponent).toHaveAttribute('skeletonType', SkeletonType.Text); + }); + + test('Should use corresponding skeleton component for loader type circle', () => { + spectator = createHost(``); + + expect(spectator.query('.ht-loader')).toExist(); + expect(spectator.query('.ht-loader')).not.toHaveClass('flex-centered'); + + const skeletonComponent = spectator.query(SkeletonComponent); + expect(skeletonComponent).toExist(); + expect(skeletonComponent).toHaveAttribute('skeletonType', SkeletonType.Circle); + }); + + test('Should use corresponding skeleton component for loader type square', () => { + spectator = createHost(``); + + expect(spectator.query('.ht-loader')).toExist(); + expect(spectator.query('.ht-loader')).not.toHaveClass('flex-centered'); + + const skeletonComponent = spectator.query(SkeletonComponent); + expect(skeletonComponent).toExist(); + expect(skeletonComponent).toHaveAttribute('skeletonType', SkeletonType.Square); + }); + + test('Should use corresponding skeleton component for loader type table row', () => { + spectator = createHost(``); + + expect(spectator.query('.ht-loader')).toExist(); + expect(spectator.query('.ht-loader')).not.toHaveClass('flex-centered'); + + const skeletonComponent = spectator.query(SkeletonComponent); + expect(skeletonComponent).toExist(); + expect(skeletonComponent).toHaveAttribute('skeletonType', SkeletonType.TableRow); + }); + + test('Should use corresponding skeleton component for loader type donut', () => { + spectator = createHost(``); + + expect(spectator.query('.ht-loader')).toExist(); + expect(spectator.query('.ht-loader')).not.toHaveClass('flex-centered'); + + const skeletonComponent = spectator.query(SkeletonComponent); + expect(skeletonComponent).toExist(); + expect(skeletonComponent).toHaveAttribute('skeletonType', SkeletonType.Donut); + }); + + test('Should use corresponding skeleton component for loader type list item', () => { + spectator = createHost(``); + + expect(spectator.query('.ht-loader')).toExist(); + expect(spectator.query('.ht-loader')).not.toHaveClass('flex-centered'); + + const skeletonComponent = spectator.query(SkeletonComponent); + expect(skeletonComponent).toExist(); + expect(skeletonComponent).toHaveAttribute('skeletonType', SkeletonType.ListItem); + }); }); diff --git a/projects/components/src/load-async/loader/loader.component.ts b/projects/components/src/load-async/loader/loader.component.ts index 04f697ec0..b1a0ad038 100644 --- a/projects/components/src/load-async/loader/loader.component.ts +++ b/projects/components/src/load-async/loader/loader.component.ts @@ -1,5 +1,7 @@ import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core'; import { ImagesAssetPath } from '@hypertrace/assets-library'; +import { assertUnreachable } from '@hypertrace/common'; +import { SkeletonType } from '../../skeleton/skeleton.component'; import { LoaderType } from '../load-async.service'; @Component({ @@ -7,8 +9,14 @@ import { LoaderType } from '../load-async.service'; styleUrls: ['./loader.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -
- +
+ + + + + + +
` }) @@ -16,10 +24,43 @@ export class LoaderComponent implements OnChanges { @Input() public loaderType?: LoaderType; + public skeletonType: SkeletonType = SkeletonType.Rectangle; + public currentLoaderType: LoaderType = LoaderType.Spinner; + public imagePath: ImagesAssetPath = ImagesAssetPath.LoaderSpinner; + + public isOldLoaderType: boolean = true; + public ngOnChanges(): void { this.currentLoaderType = this.loaderType ?? LoaderType.Spinner; + + if (this.determineIfOldLoaderType(this.currentLoaderType)) { + this.isOldLoaderType = true; + this.imagePath = this.getImagePathFromType(this.currentLoaderType); + } else { + this.isOldLoaderType = false; + this.skeletonType = this.getSkeletonTypeForLoader(this.currentLoaderType); + } + } + + public determineIfOldLoaderType(loaderType: LoaderType): boolean { + switch (loaderType) { + case LoaderType.Spinner: + case LoaderType.ExpandableRow: + case LoaderType.Page: + return true; + case LoaderType.Circle: + case LoaderType.Text: + case LoaderType.ListItem: + case LoaderType.Rectangle: + case LoaderType.Square: + case LoaderType.TableRow: + case LoaderType.Donut: + return false; + default: + return assertUnreachable(loaderType); + } } public getImagePathFromType(loaderType: LoaderType): ImagesAssetPath { @@ -33,4 +74,23 @@ export class LoaderComponent implements OnChanges { return ImagesAssetPath.LoaderSpinner; } } + + public getSkeletonTypeForLoader(curLoaderType: LoaderType): SkeletonType { + switch (curLoaderType) { + case LoaderType.Text: + return SkeletonType.Text; + case LoaderType.Circle: + return SkeletonType.Circle; + case LoaderType.Square: + return SkeletonType.Square; + case LoaderType.TableRow: + return SkeletonType.TableRow; + case LoaderType.ListItem: + return SkeletonType.ListItem; + case LoaderType.Donut: + return SkeletonType.Donut; + default: + return SkeletonType.Rectangle; + } + } } diff --git a/projects/components/src/skeleton/skeleton.component.scss b/projects/components/src/skeleton/skeleton.component.scss new file mode 100644 index 000000000..24b196237 --- /dev/null +++ b/projects/components/src/skeleton/skeleton.component.scss @@ -0,0 +1,161 @@ +@import 'color-palette'; + +@mixin loading-animation { + content: ''; + animation: skeleton-animation 1.2s infinite; + height: 100%; + left: 0; + position: absolute; + right: 0; + top: 0; + transform: translateX(-100%); + z-index: 1; + background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0)); +} +@keyframes skeleton-animation { + from { + transform: translateX(-100%); + } + to { + transform: translateX(100%); + } +} + +@mixin donut-animation { + content: ''; + animation: donut-spin 1.2s linear infinite; + transform-origin: 50% 0%; + height: 100%; + width: 100%; + position: absolute; + top: 50%; + left: 0; + background: linear-gradient(90deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0)); +} +@keyframes donut-spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +@mixin initial-location { + top: initial; + left: initial; + transform: initial; +} + +@mixin block-parent-center { + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.skeleton { + @include block-parent-center; + &::after { + @include loading-animation; + } + position: relative; + background-color: $gray-2; + overflow: hidden; + border-radius: 6px; + + &.rectangle { + height: 80%; + width: 80%; + } + + &.text { + @include initial-location; + height: 1.3rem; + width: 90%; + margin: 10px; + } + + &.circle { + width: 2rem; + height: 2rem; + border-radius: 50%; + } + + &.square { + width: 2rem; + height: 2rem; + } + + &.table-row { + @include initial-location; + height: 1.3rem; + width: 90%; + margin-left: 10px; + } + + &.donut { + display: inline-block; + background-color: $gray-2; + border-radius: 50%; + width: 40%; + padding-bottom: 40%; + + .donut-inner { + @include block-parent-center; + width: 70%; + height: 70%; + position: absolute; + background-color: white; + z-index: 1; + border-radius: 50%; + } + } + + &.donut::after { + @include donut-animation; + } + + &.list-item { + @include initial-location; + background-color: white; + display: flex; + justify-content: flex-start; + + .item-circle { + background-color: $gray-2; + height: 2.5rem; + width: 2.5rem; + border-radius: 50%; + } + .item-circle::after { + @include loading-animation; + } + + .item-column { + display: flex; + flex-direction: column; + padding-left: 1rem; + width: 100%; + + .item-line { + background-color: $gray-2; + height: 0.7rem; + border-radius: 6px; + width: 90%; + + &:last-child { + margin-top: 5px; + width: 80%; + } + } + } + + .item-column > div::after { + @include loading-animation; + } + } + + &.repeating { + margin-top: 1rem; + } +} diff --git a/projects/components/src/skeleton/skeleton.component.test.ts b/projects/components/src/skeleton/skeleton.component.test.ts new file mode 100644 index 000000000..ef539c146 --- /dev/null +++ b/projects/components/src/skeleton/skeleton.component.test.ts @@ -0,0 +1,72 @@ +import { createHostFactory, SpectatorHost } from '@ngneat/spectator/jest'; +import { SkeletonComponent, SkeletonType } from './skeleton.component'; + +describe('Skeleton Component', () => { + const createHost = createHostFactory({ + component: SkeletonComponent + }); + + let spectator: SpectatorHost; + + test('Should only be one skeleton element by default', () => { + spectator = createHost(``); + + expect(spectator.query('.skeleton.rectangle')).toExist(); + expect(spectator.query('.repeating')).not.toExist(); + expect(spectator.queryAll('.skeleton.rectangle').length).toEqual(1); + }); + + test('Should display number of skeleton elements equal to the repeat input', () => { + spectator = createHost(``); + + expect(spectator.query('.skeleton.list-item')).toExist(); + expect(spectator.query('.skeleton .item-circle')).toExist(); + expect(spectator.queryAll('.skeleton.repeating')).toHaveLength(4); + }); + + test('Should match the skeleton type to the corresponding element', () => { + const skeletonInputData: { type: SkeletonType; expectedRepeat: number }[] = [ + { + type: SkeletonType.Donut, + expectedRepeat: 1 + }, + { + type: SkeletonType.Text, + expectedRepeat: 1 + }, + { + type: SkeletonType.Rectangle, + expectedRepeat: 1 + }, + { + type: SkeletonType.Circle, + expectedRepeat: 1 + }, + { + type: SkeletonType.TableRow, + expectedRepeat: 5 + }, + { + type: SkeletonType.Square, + expectedRepeat: 1 + }, + { + type: SkeletonType.ListItem, + expectedRepeat: 4 + } + ]; + spectator = createHost(``, { + hostProps: { + skeletonType: SkeletonType.Donut + } + }); + + skeletonInputData.forEach(testConfig => { + spectator.setHostInput({ skeletonType: testConfig.type }); + + const shapeContainerClass = `.${testConfig.type}`; + expect(spectator.query(shapeContainerClass)).toExist(); + expect(spectator.queryAll(shapeContainerClass)).toHaveLength(testConfig.expectedRepeat); + }); + }); +}); diff --git a/projects/components/src/skeleton/skeleton.component.ts b/projects/components/src/skeleton/skeleton.component.ts new file mode 100644 index 000000000..0df006dba --- /dev/null +++ b/projects/components/src/skeleton/skeleton.component.ts @@ -0,0 +1,76 @@ +import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core'; + +@Component({ + selector: 'ht-skeleton', + template: ` + + +
+
+
+
+
+
+
+
+
+
+
+
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrls: ['./skeleton.component.scss'] +}) +export class SkeletonComponent implements OnChanges { + private static readonly SKELETON_CLASS_NAME: string = 'skeleton'; + private static readonly REPEATING_CLASS_NAME: string = 'repeating'; + + @Input() + public skeletonType: SkeletonType = SkeletonType.Rectangle; + + public iterationsArray: number[] = Array(1).fill(1); + + public containerClass: string[]; + + public constructor() { + this.containerClass = this.getContainerClass(); + } + + public ngOnChanges(): void { + this.iterationsArray = this.getIterationsArray(); + + this.containerClass = this.getContainerClass(); + } + + public getIterationsArray(): number[] { + switch (this.skeletonType) { + case SkeletonType.TableRow: + return Array(5).fill(1); + case SkeletonType.ListItem: + return Array(4).fill(1); + default: + return Array(1).fill(1); + } + } + + public getContainerClass(): string[] { + const classes = [SkeletonComponent.SKELETON_CLASS_NAME, this.skeletonType]; + + if (this.skeletonType === SkeletonType.TableRow || this.skeletonType === SkeletonType.ListItem) { + classes.push(SkeletonComponent.REPEATING_CLASS_NAME); + } + + return classes; + } +} + +export const enum SkeletonType { + Rectangle = 'rectangle', + Text = 'text', + Square = 'square', + Circle = 'circle', + TableRow = 'table-row', + ListItem = 'list-item', + Donut = 'donut' +} diff --git a/projects/components/src/skeleton/skeleton.module.ts b/projects/components/src/skeleton/skeleton.module.ts new file mode 100644 index 000000000..c4ee4d08e --- /dev/null +++ b/projects/components/src/skeleton/skeleton.module.ts @@ -0,0 +1,10 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { SkeletonComponent } from './skeleton.component'; + +@NgModule({ + declarations: [SkeletonComponent], + imports: [CommonModule], + exports: [SkeletonComponent] +}) +export class SkeletonModule {}