donald23
donald23

Reputation: 45

Updating a template using signals within a @for loop is not working

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:

  1. sales.service.ts:
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);
    }
  }
}

  1. dashboard.component.ts:
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;
  }
};
  1. dashboard.component.html:
<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>

Upvotes: 2

Views: 421

Answers (2)

donald23
donald23

Reputation: 45

With the answer from @mat.hudak in mind and starting to work on that. Including giving an Observable back from my sales.service, I went in, trying to keep using signals.

These are the key parts I actually used and worked for me: sales.service.ts:

ticketsSoldPerPeriod (editionId: number, startOffset: number = 0, endOffset: number = 0): Observable<number> {
    const start = this.calculateOffset(startOffset);
    const end = this.calculateOffset(endOffset);
    if (this.users.userToken !== undefined) {
      return this.apis.ticketsSoldPerPeriod(start, end, editionId, this.users.userToken().userToken)
        .pipe(
          tap((resData) => {
            console.log(resData);
          }),
          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;
          })
        );
    } else {
      console.error(`No logged in user!! (sales.service)`);
      return EMPTY;
    }
  }

  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));
  }

dashboard.component.ts:

import { Component, effect, inject, WritableSignal, signal, untracked } from '@angular/core';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { provideIcons } from '@ng-icons/core';

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 } from 'src/app/core/interfaces/core-interfaces';
import { WidgetSalesLastWeekComponent } from './widget-sales-last-week/widget-sales-last-week.component';

@Component({
  selector: 'app-dashboard',
  imports: [BreadcrumbsComponent, NgbModule, WidgetSalesLastWeekComponent],
  providers: [provideIcons({})],
  templateUrl: './dashboard.component.html',
  styleUrl: './dashboard.component.scss'
})
export class DashboardComponent {
  // 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 salesLastWeekData: WritableSignal<WidgetData[]> = signal([]);
  protected ticketsLastWeek: WritableSignal<number> = signal(0);
  protected ticketsPreviousWeek: WritableSignal<number> = signal(0);
  private currentActiveEditionId: number = 0;

  constructor() {
    effect(() => {
      if (this.currentActiveEditionId = this.sales.currentActiveEdition().edition_id) {
        console.warn(`Current active edition: ${ this.currentActiveEditionId }`);
        let quantityPreviousWeek = this.ticketsPreviousWeek();
        untracked(() => {
          this.sales.ticketsSoldPerPeriod(this.currentActiveEditionId, -7, 0).subscribe(res => this.ticketsLastWeek.set(res));
          this.sales.ticketsSoldPerPeriod(this.currentActiveEditionId, -14, -7).subscribe(res => this.ticketsPreviousWeek.set(res));
          console.info(`hier dan? ${ this.ticketsLastWeek() } en ${ this.ticketsPreviousWeek() }`);
          this.salesLastWeekData.set([{
            widgetId: 1,
            header: 'Tickets sold',
            subTitle: 'Sold in the last week',
            iconName: 'bootstrapCart',
            data: this.ticketsLastWeek()?.toString() + ' tickets',
            deviationPercentage: parseInt(((((this.ticketsLastWeek() ?? 1) / (this.ticketsPreviousWeek() ?? 1)) - 1) * 100).toFixed(1))
          }]);
        });
        console.warn('done updating in updateWidget');
      }
    });
    console.log(this.salesLastWeekData());
  }
}

Upvotes: 0

mat.hudak
mat.hudak

Reputation: 3202

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.

Upvotes: 1

Related Questions