I have been trying and experimenting with a load of different approaches, but I cannot seem to get it working that my template gets updated.
Currently the situation: I have an Angular 19 app, completely zoneless (so without zone.js and using provideExperimentalZonelessChangeDetection
in my app.config.ts.
I was also having the same issue before, in Angular 18.
I have a dashboard, that you can only reach after logging in. That part works fine.
On that dashboard I want to display a small widget, showing the number of tickets sold in the last week and percentage of increase or decrease compared to the week before.
Creating the widget works fine, it even adds more if I change editions (at the top, in my header, I can select an edition and it then tries to retrieve the sales data for that edition). It does create an extra widget when I do that. And the sales.service.ts
shows getting the correct numbers from the API.
It's just not updating the template/website that the user is watching and my creative thinking is exhausted. I hope someone here can set me on the right track again.
Here are the relevant files:
import { Injectable, inject, DestroyRef, Signal, signal, WritableSignal } from '@angular/core';
import { Observable, of } from 'rxjs';
import { ApisService } from './apis.service';
import { Edition } from './interfaces/api-interfaces';
import { UsersService } from './users.service';
import { ToastsService } from './toasts.service';
@Injectable({
providedIn: 'root'
})
export class SalesService {
// First injections
private destroyRef: DestroyRef = inject(DestroyRef);
private toasts: ToastsService = inject(ToastsService);
private apis: ApisService = inject(ApisService);
private users: UsersService = inject(UsersService);
// Then other declarations
private _editions: WritableSignal<Edition[]> = signal([]);
constructor() { }
get editions (): Signal<Edition[]> {
return this._editions;
}
get currentActiveEdition (): Signal<Edition> {
return signal(this._editions().find(edition => edition.current_active === true) ?? { edition_id: 0, name: '', event_date: 0, default: 0, current_active: true });
}
getCurrentEditions (): void {
if (this.users.userToken !== undefined) {
const ticketSubscription = this.apis.ticketsEditions(this.users.userToken().userToken)
.subscribe({
next: (resData) => {
if (resData.length > 0) {
this._editions.set(resData);
this.setCurrentActiveEdition(resData.find(edition => edition.default === 1)!.edition_id);
} else {
this.users.userServiceError.set(true);
this.users.errorMsg.set({ status: 'invalid request', message: 'No editions found' });
this.toasts.show({ id: this.toasts.toastCount + 1, type: 'error', header: this.users.errorMsg().status, body: this.users.errorMsg().message, delay: 10000 });
}
}, error: (err) => {
this.users.userServiceError.set(true);
this.users.errorMsg.set(err.error);
this.toasts.show({ id: this.toasts.toastCount + 1, type: 'error', header: this.users.errorMsg().status, body: this.users.errorMsg().message, delay: 10000 });
}
});
this.destroyRef.onDestroy(() => {
ticketSubscription.unsubscribe();
});
} else {
console.error(`No logged in user!! (sales.service)`);
}
}
setCurrentActiveEdition (newActiveEdition: number): void {
this._editions.update(editions => {
return editions.map(edition => {
return edition.edition_id === newActiveEdition ? { ...edition, current_active: true } : { ...edition, current_active: false };
});
});
}
ticketsSoldPerPeriod (start: number, end: number, editionId: number): Observable<number | null> {
console.info(`Start date from timestamp: ${ start }`);
console.info(`End date from timestamp: ${ end }`);
if (this.users.userToken !== undefined) {
const ticketSubscription = this.apis.ticketsSoldPerPeriod(start, end, editionId, this.users.userToken().userToken)
.subscribe({
next: (resData) => {
console.log(resData);
return of(resData);
}, error: (err) => {
this.users.userServiceError.set(true);
this.users.errorMsg.set(err.error);
this.toasts.show({ id: this.toasts.toastCount + 1, type: 'error', header: this.users.errorMsg().status, body: this.users.errorMsg().message, delay: 10000 });
return of(null);
}
});
this.destroyRef.onDestroy(() => {
ticketSubscription.unsubscribe();
});
return of(null);
} else {
console.error(`No logged in user!! (sales.service)`);
return of(null);
}
}
}
import { Component, effect, inject, OnInit, WritableSignal, Signal, signal, computed } from '@angular/core';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { bootstrapCart } from '@ng-icons/bootstrap-icons';
import { Breadcrumb, BreadcrumbsComponent } from 'src/app/core/header/breadcrumbs/breadcrumbs.component';
import { UsersService } from 'src/app/core/users.service';
import { SalesService } from 'src/app/core/sales.service';
import { WidgetData, DashboardWidgetComponent } from './dashboard-widget/dashboard-widget.component';
@Component({
selector: 'app-dashboard',
imports: [BreadcrumbsComponent, NgbModule, DashboardWidgetComponent, NgIconComponent],
providers: [provideIcons({ bootstrapCart })],
templateUrl: './dashboard.component.html',
styleUrl: './dashboard.component.scss'
})
export class DashboardComponent implements OnInit {
// First injections
private users: UsersService = inject(UsersService);
private sales: SalesService = inject(SalesService);
// Then other declarations
protected breadcrumbs: Breadcrumb[] = [{ linkId: 1, linkable: false, text: 'Dashboard' }];
protected widgetInputData: WritableSignal<WidgetData[]> = signal([]);
protected ticketsLastWeek: WritableSignal<number | null> = signal(null);
protected ticketsPreviousWeek: WritableSignal<number | null> = signal(null);
private currentActiveEditionId: number = 0;
constructor() {
effect(() => {
if (this.currentActiveEditionId = this.sales.currentActiveEdition().edition_id) {
console.warn(`Current active edition: ${ this.currentActiveEditionId }`);
this.sales.ticketsSoldPerPeriod(Math.floor((Date.now() / 1000)) - 7 * (24 * 3600), Math.floor(Date.now() / 1000), this.currentActiveEditionId).subscribe({
next: (quantity) => {
this.ticketsLastWeek.set(quantity);
this.sales.ticketsSoldPerPeriod(Math.floor((Date.now() / 1000)) - 14 * (24 * 3600), Math.floor(Date.now() / 1000) - 7 * (24 * 3600), this.currentActiveEditionId).subscribe({
next: (quantity) => {
this.ticketsPreviousWeek.set(quantity);
console.warn(`hier dan?`);
this.widgetInputData.update(widgetData => this.updateWidgetData(widgetData));
}
});
}
});
}
});
}
ngOnInit (): void { }
private updateWidgetData (widgetData: WidgetData[]): WidgetData[] {
console.warn(this.ticketsLastWeek());
console.warn(this.ticketsPreviousWeek());
let widgetDataNew = [...widgetData, {
widgetId: widgetData.length + 1,
header: 'Tickets sold',
subTitle: 'Sold in the last week',
iconName: 'bootstrapCart',
data: this.ticketsLastWeek()?.toString() + ' tickets',
deviationPercentage: (((this.ticketsLastWeek() ?? 1) / (this.ticketsPreviousWeek() ?? 1)) - 1)
}];
console.log(widgetDataNew);
return widgetDataNew;
}
};
<app-breadcrumbs [breadcrumbs]="breadcrumbs"></app-breadcrumbs>
<div class="container">
<div class="row justify-content-center">
@for (widget of widgetInputData(); track widget.widgetId; let idx = $index) {
<div class="col-xxl-3 col-md-3">
<div class="card border-info mb-3">
<div class="card-header bg-info text-white">
<h3>{{ widget.header }}</h3>
</div>
<div class="card-body">
<h6 class="card-subtitle text-muted">{{ widget.subTitle }}</h6>
</div>
<div class="d-flex align-items-center card-body">
<div class="card-icon rounded-circle d-flex align-items-center justify-content-center dashboard-widget">
<ng-icon name="bootstrapCart" size="1em"></ng-icon>
</div>
<div class="ps-3 card-text">
<h6>{{ widget.data }}</h6>
@if (widget.deviationPercentage) {
@if (widget.deviationPercentage === 0) {
<span class="text-info small pt-1 fw-bold"> {{ widget.deviationPercentage }}%
</span>
<span class="text-muted small pt-2 ps-1"> steady </span>
} @else if (widget.deviationPercentage < 0) { <span class="text-danger small pt-1 fw-bold">
{{ widget.deviationPercentage }}%
</span>
<span class="text-muted small pt-2 ps-1"> decrease </span>
} @else if (widget.deviationPercentage > 0) {
<span class="text-success small pt-1 fw-bold">
{{ widget.deviationPercentage }}% </span>
<span class="text-muted small pt-2 ps-1"> increase </span>
}
}
<!-- @for (edition of editions(); track edition.edition_id) {
<span class="text-warning fw-bold">{{ edition.name }}</span>
} @empty {
<span class="text-warning fw-bold">No editions found!</span>
} -->
</div>
</div>
</div>
</div>
}
</div>
</div>
As I said in the comment, there are so many issues in the sample code, that it's difficult to tell what's the actual cause of your problem.
I strongly suspect that the issue is caused by this implementation:
ticketsSoldPerPeriod (start: number, end: number, editionId: number): Observable<number | null> {
// Note: I would expect, that you get to this point only if you are sure, that the user is authenticated.
// If not, than your autentication is not handled correctly
if (this.users.userToken !== undefined) {
// 1. You make the ASYNC request to obtain the data, which can take some time
const ticketSubscription = this.apis.ticketsSoldPerPeriod(start, end, editionId, this.users.userToken().userToken)
.subscribe(...);
// 2. But execution of the code continues
this.destroyRef.onDestroy(() => {
// NOTE: sales.service.ts is provided in root - it lives as long as the applicationn does
// So it will be destroyed only when the application is destroyed, this is useless code
ticketSubscription.unsubscribe();
});
// 3. And it returns null, before ticketsSoldPerPeriod has a chance to finish
// That way, you see that the call to the API was made, but you are returning null instead. Because of ill designed function
return of(null);
} else {
console.error(`No logged in user!! (sales.service)`);
return of(null);
}
}
What's the point of subscribing to the API call in the service? You can return it directly. Once it's done, you'll get the result you want and you don't need to fake it with return of(null)
. Plus, offset day calculation is best hidden in the service, as a function to avoid writing same code multiple times - DRY principle. Just past the offset days as a parameters.
private calculateOffset(offset: number = 0): number {
// If offset is set to zero, nothing will be added. Negative numbers will be subtracted
return Math.floor((Date.now() / 1000)) + (offset * (24 * 3600));
}
ticketsSoldPerPeriod (editionId: number, startOffset: number = 0, endOffset: number = 0): Observable<number> {
// DRY - make a function, which takes offset as a parameter and write the calculation only once
const start = this.calculateOffset(startOffset);
const end = this.calculateOffset(endOffset);
// No point of subscribing and returning the value back using of(), when you can just return it.
return this.apis.ticketsSoldPerPeriod(start, end, editionId, this.users.userToken().userToken).pipe(
tap((resData) => console.log(resData)),
take(1), // Handles unsubscribe, if you want to be sure, but HTTP client takes care of that.
catchError(err => {
this.users.userServiceError.set(true);
this.users.errorMsg.set(err.error);
this.toasts.show({ id: this.toasts.toastCount + 1, type: 'error', header: this.users.errorMsg().status, body: this.users.errorMsg().message, delay: 10000 });
return EMPTY;
})
)
}
While the modification above might solve the issue, it's still worth rewriting the way it's handled in the component. I would avoid doing it in the constructors as at that time, not everything might be ready.
import { toObservable } from '@angular/core/rxjs-interop';
import { tap, filter, switchMap, forkJoin } from 'rxjs';
// Constructor can be deleted
constructor() {}
// Handle on once the component is initialized
ngOnInit (): void {
// Make an observable out of your signal
toObservable<number>(this.sales.currentActiveEdition()).pipe(
// Continue only if ID is non-zero.
filter((edition_id) => edition_id > 0),
// Save it to the class variable
tap(edition_id => this.currentActiveEditionId = edition_id),
// Now switchMap it and obtain both periods at once, using forkJoin
switchMap(editionId => forkJoin({
// Serivce methods now expect days offset, it'll will calculate it accordingly
quantityLastWeek: this.sales.ticketsSoldPerPeriod(editionId, -7, 0),
quantityPreviousWeek: this.sales.ticketsSoldPerPeriod(editionId, -14, -7)
}),
// Use operators to handle unsubcribe
takeUntilDestroyed(this.destroyRef)
).subscribe(({quantityLastWeek, quantityPreviousWeek}) => {
// Subscribe and set the values
this.ticketsLastWeek.set(quantityLastWeek);
this.ticketsPreviousWeek.set(quantityPreviousWeek);
// Updated the signal
this.widgetInputData.update(widgetData => this.updateWidgetData(widgetData));
})
);
}
As for other issues, this is already a long answer, so I'm not going to deal with them. Long story short.