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 {}