feat: prepare skeleton loading indicator

This commit is contained in:
Marcel Arndt 2025-06-30 07:52:27 +02:00
parent 96414c60b9
commit 5f37743019
13 changed files with 184 additions and 49 deletions

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SkeletonRectComponent } from './skeleton-rect.component';
describe('SkeletonRectComponentComponent', () => {
let component: SkeletonRectComponent;
let fixture: ComponentFixture<SkeletonRectComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SkeletonRectComponent]
})
.compileComponents();
fixture = TestBed.createComponent(SkeletonRectComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,57 @@
import { Component, ElementRef } from '@angular/core';
@Component({
selector: 'app-skeleton-rect',
imports: [],
host: {
class: 'pulse',
},
template: `<div
role="status"
class="flex items-center justify-center h-56 max-w-sm bg-gray-300 rounded-lg animate-pulse dark:bg-gray-700"
>
<!-- <svg
class="w-10 h-10 text-gray-200 dark:text-gray-600"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 16 20"
>
<path
d="M5 5V.13a2.96 2.96 0 0 0-1.293.749L.879 3.707A2.98 2.98 0 0 0 .13 5H5Z"
/>
<path
d="M14.066 0H7v5a2 2 0 0 1-2 2H0v11a1.97 1.97 0 0 0 1.934 2h12.132A1.97 1.97 0 0 0 16 18V2a1.97 1.97 0 0 0-1.934-2ZM9 13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2Zm4 .382a1 1 0 0 1-1.447.894L10 13v-2l1.553-1.276a1 1 0 0 1 1.447.894v2.764Z"
/>
</svg> -->
<span class="sr-only">Loading...</span>
</div>`,
styles: [
`
:host {
/* display: block;
width: var(--skeleton-rect-width);
height: var(--skeleton-rect-height);
background: rgb(239, 241, 246) no-repeat; */
}
`,
],
})
export class SkeletonRectComponent {
width?: string;
height?: string;
className?: string;
constructor(private host: ElementRef<HTMLElement>) {}
ngOnInit() {
const host = this.host.nativeElement;
if (this.className) {
host.classList.add(this.className);
}
host.style.setProperty('--skeleton-rect-width', this.width ?? '100%');
host.style.setProperty('--skeleton-rect-height', this.height ?? '20px');
}
}

View File

@ -0,0 +1,43 @@
import {
Directive,
Input,
TemplateRef,
ViewContainerRef,
SimpleChanges,
} from '@angular/core';
import { SkeletonRectComponent } from './skeleton-rect.component';
function random(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
@Directive({ selector: '[skeleton]' })
export class SkeletonDirective {
@Input('skeleton') isLoading = false;
@Input('skeletonRepeat') size = 1;
@Input('skeletonWidth') width?: string;
@Input('skeletonHeight') height?: string;
@Input('skeletonClassName') className?: string;
constructor(private tpl: TemplateRef<any>, private vcr: ViewContainerRef) {}
ngOnChanges(changes: SimpleChanges) {
if (changes['isLoading']) {
this.vcr.clear();
if (changes['isLoading'].currentValue) {
Array.from({ length: this.size }).forEach(() => {
const ref = this.vcr.createComponent(SkeletonRectComponent);
Object.assign(ref.instance, {
width: this.width === 'rand' ? `${random(30, 90)}%` : this.width,
height: this.height,
className: this.className,
});
});
} else {
this.vcr.createEmbeddedView(this.tpl);
}
}
}
}

View File

@ -1,11 +1,10 @@
import { ErrorHandler, Injectable } from '@angular/core';
import { faro } from '@grafana/faro-web-sdk';
@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
handleError(error: any) {
if (error instanceof Error) {
faro.api.pushError(error);
// faro.api.pushError(error);
}
console.error(error);
}

View File

@ -11,6 +11,7 @@ import {
import { ManagerInfoCardComponent } from '../manager/components/manager-info-card/manager-info-card.component';
import { TicketStateInfoCardComponent } from '../ticket-system/ticket-state-info-card/ticket-state-info-card.component';
import { LinkCardComponent } from './link-card/link-card.component';
import { SkeletonDirective } from '../../core/components/skeleton-rect/skeleton.directive';
@Component({
selector: 'app-home',
@ -21,6 +22,7 @@ import { LinkCardComponent } from './link-card/link-card.component';
LinkCardComponent,
MatDividerModule,
CurrencyPipe,
SkeletonDirective,
],
template: `
<div class="flex flex-col m-0 md:m-4">
@ -196,12 +198,14 @@ export class HomeComponent implements OnInit {
};
}, {} as Record<string, number[]>);
return Object.entries(groupedByOrdinanceType).map(([ot, revenues]) => {
return {
label: ot,
value: revenues.at(-1), //.reduce((sum, cur) => (sum += cur), 0),
};
});
return Object.entries(groupedByOrdinanceType)
.sort(([ot1], [ot2]) => ot1.localeCompare(ot2))
.map(([ot, revenues]) => {
return {
label: ot,
value: revenues.at(-1), //.reduce((sum, cur) => (sum += cur), 0),
};
});
});
constructor(private readonly homeDashboardGql: HomeDashboardGQL) {

View File

@ -1,3 +1,6 @@
<!-- <ng-container
*skeleton="isLoading(); repeat: 5; className: 'mr-2'; height: '50px'"
></ng-container> -->
@if (kpi()?.main?.label) {
<app-info-card
[chartValue]="kpi()?.main?.value!"

View File

@ -7,27 +7,31 @@ import {
Metric,
} from '../../../../core/data-access/graphql/generated/generated';
import { ManagerDashboardService } from '../../manager-dashboard.service';
import { SkeletonDirective } from '../../../../core/components/skeleton-rect/skeleton.directive';
@Component({
selector: 'app-manager-info-card',
imports: [InfoCardModule, DecimalPipe],
imports: [InfoCardModule, DecimalPipe, SkeletonDirective],
templateUrl: './manager-info-card.component.html',
styleUrl: './manager-info-card.component.scss',
})
export class ManagerInfoCardComponent {
private service = inject(ManagerDashboardService);
isLoading = signal<boolean>(false);
ordinanceType = input.required<string>();
kpi = signal<KpiInfo | undefined>(undefined);
metrics = signal<Metric[]>([]);
constructor() {
effect(() => {
this.isLoading.set(true);
this.service
.fetchManagerKpi(this.ordinanceType())
.pipe(
tap((res) => this.metrics.set(res.additional)),
tap((res) => this.kpi.set(res))
tap((res) => this.kpi.set(res)),
tap(() => this.isLoading.set(false))
)
.subscribe();
});

View File

@ -4,33 +4,16 @@
(filterChange)="filterChanged($event)"
></dks-ticket-lane-header>
@if (tickets$ | async; as tickets) {
<cdk-virtual-scroll-viewport
itemSize="104"
(scrolledIndexChange)="fetchNextPage()"
>
<dks-ticket-item
*cdkVirtualFor="let ticket of tickets; trackBy: trackByFn"
class="mat-elevation-z4 animate-fadeIn"
(click)="openTicketView(ticket)"
[ticket]="ticket"
>
</dks-ticket-item>
</cdk-virtual-scroll-viewport>
<!-- <ng-container *ngIf="ticketStateMeta$ | async as ticketStateMeta">
<dks-ticket-lane-footer
*dksRole="['manager', 'admin']"
[ticketStateMeta]="ticketStateMeta"
[withTicketCount]="true"
[withApprovalRequiredCount]="
ticketState === TicketState.UserInteractionRequired
"
[withApprovalUnknownCount]="
ticketState === TicketState.UserInteractionRequired
"
></dks-ticket-lane-footer>
<dks-ticket-lane-footer
*dksRole="['controlcenter']"
[ticketStateMeta]="ticketStateMeta"
></dks-ticket-lane-footer
></ng-container> -->
<cdk-virtual-scroll-viewport
itemSize="104"
(scrolledIndexChange)="fetchNextPage()"
>
<dks-ticket-item
*cdkVirtualFor="let ticket of tickets; trackBy: trackByFn"
class="mat-elevation-z4 animate-fadeIn"
(click)="openTicketView(ticket)"
[ticket]="ticket"
>
</dks-ticket-item>
</cdk-virtual-scroll-viewport>
}

View File

@ -52,8 +52,8 @@ import { TicketLaneHeaderComponent } from './ticket-lane-header.component';
TicketLaneHeaderComponent,
TicketItemComponent,
AsyncPipe,
ScrollingModule
],
ScrollingModule,
],
})
export class TicketLaneComponent {
private dialog = inject(DialogService);

View File

@ -1,3 +1,6 @@
<!-- <ng-container
*skeleton="isLoading(); repeat: 5; className: 'mr-2'; height: '50px'"
></ng-container> -->
@if (kpi()?.main?.label) {
<app-info-card
[chartValue]="kpi()?.main?.value!"

View File

@ -8,27 +8,31 @@ import {
TicketValidationState,
} from '../../../core/data-access/graphql/generated/generated';
import { TicketsService } from '../tickets.service';
import { SkeletonDirective } from '../../../core/components/skeleton-rect/skeleton.directive';
@Component({
selector: 'app-ticket-state-info-card',
imports: [InfoCardModule, DecimalPipe],
imports: [InfoCardModule, DecimalPipe, SkeletonDirective],
templateUrl: './ticket-state-info-card.component.html',
styleUrl: './ticket-state-info-card.component.scss',
})
export class TicketStateInfoCardComponent {
private service = inject(TicketsService);
isLoading = signal<boolean>(false);
ticketState = input.required<TicketValidationState>();
kpi = signal<KpiInfo | undefined>(undefined);
metrics = signal<Metric[]>([]);
constructor() {
effect(() => {
this.isLoading.set(true);
this.service
.fetchTicketStateKpi(this.ticketState())
.pipe(
tap((res) => this.metrics.set(res.additional)),
tap((res) => this.kpi.set(res))
tap((res) => this.kpi.set(res)),
tap(() => this.isLoading.set(false))
)
.subscribe();
});

View File

@ -44,11 +44,6 @@ import { TicketsSearchDialogComponent } from './tickets-search-dialog/tickets-se
class="overflow-y-auto mat-elevation-z2"
[ticketState]="TicketState.DocumentsMissing"
></dks-ticket-lane>
<!-- <dks-ticket-lane
*dksRole="['manager', 'admin']"
class="overflow-y-auto mat-elevation-z2"
[ticketState]="TicketState.Archived"
></dks-ticket-lane> -->
</section>`,
styles: [
`

View File

@ -1,5 +1,5 @@
/* You can add global styles to this file, and also import other style files */
@use '@angular/material' as mat;
@use "@angular/material" as mat;
html,
body {
@ -11,3 +11,20 @@ body {
background: var(--mat-sys-surface-dim);
color: var(--mat-sys-on-surface);
}
.pulse {
animation: pulse 1.5s cubic-bezier(0.4, 0, 0.2, 1) infinite;
animation-delay: 0.5s;
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}