Angular

Build a Dynamic Watchlist for Your Web App with Angular & GraphQL (Part 6)

TL;DR: Implement a watchlist feature in your Angular application using the AddToWatchlist component. Integrate it with a Watchlist component via GraphQL. Learn step-by-step with code examples and best practices.

In the previous article of this series, we learned how to add login functionality and implement role-based authorization in our application.

In this article, we will cover how to implement a watchlist feature similar to the wishlist feature of e-commerce apps. When a user visits the movie details page, we will add a feature to display a list of movies from the same genre.

Create the IWatchlist interface

First, create a new file named IWatchlist.cs in the MovieApp\Interfaces folder with the following method.

public interface IWatchlist
{
    Task ToggleWatchlistItem(int userId, int movieId);

    Task<string> GetWatchlistId(int userId);

    Task ClearWatchlist(int userId);
}

Create the watchlist data access layer

Second, implement the data access layer for the watchlist functionality in a file named WatchlistDataAccessLayer.cs inside the MovieApp.Server\DataAccess folder. Add the following code inside it.

public class WatchlistDataAccessLayer: IWatchlist
{
    readonly MovieDbContext _dbContext;
    public WatchlistDataAccessLayer(IDbContextFactory<MovieDbContext> dbContext)
    {
        _dbContext = dbContext.CreateDbContext();
    }

    public async Task<string> GetWatchlistId(int userId)
    {
        try
        {
            Watchlist? watchlist = await _dbContext.Watchlists.FirstOrDefaultAsync(x => x.UserId == userId);

            if (watchlist is not null)
            {
                return watchlist.WatchlistId;
            }
            else
            {
                return await CreateWatchlist(userId);
            }

        }
        catch
        {
            throw;
        }
    }

    public async Task ToggleWatchlistItem(int userId, int movieId)
    {
        string watchlistId = await GetWatchlistId(userId);

        WatchlistItem? existingWatchlistItem = await _dbContext.WatchlistItems
            .FirstOrDefaultAsync(x => x.MovieId == movieId && x.WatchlistId == watchlistId);

        if (existingWatchlistItem is not null)
        {
            _dbContext.WatchlistItems.Remove(existingWatchlistItem);
        }
        else
        {
            WatchlistItem watchlistItem = new()
            {
                WatchlistId = watchlistId,
                MovieId = movieId,
            };

            _dbContext.WatchlistItems.Add(watchlistItem);
        }
        await _dbContext.SaveChangesAsync();
    }

    public async Task ClearWatchlist(int userId)
    {
        try
        {
            string watchlistId = await GetWatchlistId(userId);
            List<WatchlistItem> watchlistItem = _dbContext.WatchlistItems.Where(x => x.WatchlistId == watchlistId).ToList();

            if (!string.IsNullOrEmpty(watchlistId))
            {
                foreach (WatchlistItem item in watchlistItem)
                {
                    _dbContext.WatchlistItems.Remove(item);
                    await _dbContext.SaveChangesAsync();
                }
            }
        }
        catch
        {
            throw;
        }
    }

    async Task<string> CreateWatchlist(int userId)
    {
        try
        {
            Watchlist watchlist = new()
            {
                WatchlistId = Guid.NewGuid().ToString(),
                UserId = userId,
                DateCreated = DateTime.Now.Date
            };

            await _dbContext.Watchlists.AddAsync(watchlist);
            await _dbContext.SaveChangesAsync();

            return watchlist.WatchlistId;
        }
        catch
        {
            throw;
        }
    }

}

The WatchlistDataAccessLayer class implements the IWatchlist interface and utilizes an IDbContextFactory<MovieDbContext> in its constructor to instantiate a MovieDbContext, enabling seamless interaction with the database.

The GetWatchlistId is an asynchronous method for retrieving the watchlist ID for a given user ID. If a watchlist for the user doesn’t exist, it creates a new one.

The ToggleWatchlistItem is an asynchronous method for adding or removing a movie from a user’s watchlist. If the movie is already on the watchlist, it will be removed; otherwise, it will be added.

The ClearWatchlist method asynchronously removes all movies from a user’s watchlist.

The CreateWatchlist is a private asynchronous method for creating a new watchlist for a user. It generates a new GUID for the watchlist ID, sets the user ID, and sets the creation date as the current date.

In each method, database changes are saved using the asynchronous SaveChangesAsync method, which records all modifications made within the database context. The try-catch blocks within the methods ensure any potential exceptions during database interactions are handled smoothly.

Update the IMovie interface

Update the IMovie interface by adding the method declaration like in the following code example in the IMovie.cs file.

public interface IMovie
{
    // other methods.
    Task<List<Movie>> GetSimilarMovies(int movieId);

    Task<List<Movie>> GetMoviesAvailableInWatchlist(string watchlistId);
}

Update the MovieDataAccessLayer class

Update the MovieDataAccessLayer class by implementing the newly added methods of the IMovie interface.

public async Task<List<Movie>> GetSimilarMovies(int movieId)
{
    List<Movie> lstMovie = new();
    Movie? movie = await _dbContext.Movies.FindAsync(movieId);

    if (movie is not null)
    {
        lstMovie = _dbContext.Movies.Where(m => m.Genre == movie.Genre && m.MovieId != movie.MovieId)
        .OrderBy(u => Guid.NewGuid())
        .Take(5)
        .ToList();
    }
    return lstMovie;
}

public async Task<List<Movie>> GetMoviesAvailableInWatchlist(string watchlistID)
{
    try
    {
        List<Movie> userWatchlist = new();
        List<WatchlistItem> watchlistItems =
            _dbContext.WatchlistItems.Where(x => x.WatchlistId == watchlistID).ToList();

        foreach (WatchlistItem item in watchlistItems)
        {
            Movie? movie = await GetMovieData(item.MovieId);

            if (movie is not null)
            {
                userWatchlist.Add(movie);
            }
        }
        return userWatchlist;
    }
    catch
    {
        throw;
    }
}

The asynchronous method GetSimilarMovies retrieves a list of movies with the same genre as the selected movie. Identified by the movieId parameter, it queries the database to find the corresponding Movie object and stores the result in the movie variable. If the movie object is not null, it performs another query to find all the movies with the same genre but different MovieId, ensuring the list excludes the selected movie itself. The results are then randomly ordered using GUIDs to shuffle the titles. Finally, it selects and returns up to five movies from this shuffled list that are similar to the selected movie.

The GetMoviesAvailableInWatchlist method asynchronously retrieves all movies in a specific watchlist identified by the watchlistID parameter. It queries the database to get all WatchlistItem objects where the WatchlistId matches the provided watchlistID and stores the result in the watchlistItems list. The method then iterates over each watchlist item in the list, calling the GetMovieData method with the MovieId of each item to get the corresponding Movie object. If the Movie object is not null, it adds the movie to the userWatchlist. Finally, it returns the userWatchlist.

Add the GraphQL server query resolver for the watchlist

Add a class named WatchlistQueryResolver.cs inside the MovieApp\GraphQL folder. Refer to the following code example.

[ExtendObjectType(typeof(MovieQueryResolver))]
public class WatchlistQueryResolver
{
    readonly IWatchlist _watchlistService;
    readonly IMovie _movieService;
    readonly IUser _userService;

    public WatchlistQueryResolver(IWatchlist watchlistService, IMovie movieService, IUser userService)
    {
        _watchlistService = watchlistService;
        _movieService = movieService;
        _userService = userService;
    }

    [Authorize]
    [GraphQLDescription("Get the user Watchlist.")]
    public async Task<List<Movie>> GetWatchlist(int userId)
    {
        bool user = await _userService.IsUserExists(userId);

        if (user)
        {
            string watchlistid = await _watchlistService.GetWatchlistId(userId);
            return await _movieService.GetMoviesAvailableInWatchlist(watchlistid);
        }
        else
        {
            return new List<Movie>();
        }
    }
}

The WatchlistQueryResolver class has three private, read-only fields: _watchlistService, _movieService, and _userService. These fields are instances of the IWatchlist, IMovie, and IUser interfaces, respectively, and they are initialized via dependency injection in the constructor. This class extends the MovieQueryResolver type, adding additional fields to the existing GraphQL type.

The GetWatchlist method checks if a user with the provided userId exists by calling the IsUserExists method of _userService. The result is stored in the user variable. If the user exists, it gets the ID of the user’s watchlist by calling the GetWatchlistId method of _watchlistService. It then retrieves the movies available in the watchlist by calling the GetMoviesAvailableInWatchlist method of _movieService with the watchlist ID. If the user does not exist, it returns an empty list of Movie objects. This method is decorated with the Authorize attribute, which requires the client to be authenticated to call this method.

Add the GraphQL server mutation resolver for the watchlist

Add a class named WatchlistMutationResolver.cs inside the MovieApp\GraphQL folder. Refer to the following code example.

[ExtendObjectType(typeof(MovieMutationResolver))]
public class WatchlistMutationResolver
{
    readonly IWatchlist _watchlistService;
    readonly IMovie _movieService;
    readonly IUser _userService;

    public WatchlistMutationResolver(IWatchlist watchlistService, IMovie movieService, IUser userService)
    {
        _watchlistService = watchlistService;
        _movieService = movieService;
        _userService = userService;
    }


    [Authorize]
    [GraphQLDescription("Toggle Watchlist item.")]
    public async Task<List<Movie>> ToggleWatchlist(int userId, int movieId)
    {
        await _watchlistService.ToggleWatchlistItem(userId, movieId);

        bool user = await _userService.IsUserExists(userId);

        if (user)
        {
            string watchlistid = await _watchlistService.GetWatchlistId(userId);
            return await _movieService.GetMoviesAvailableInWatchlist(watchlistid);
        }
        else
        {
            return new List<Movie>();
        }
    }

    [Authorize]
    [GraphQLDescription("Delete all items from Watchlist.")]
    public async Task<int> ClearWatchlist(int userId)
    {
        await _watchlistService.ClearWatchlist(userId);
        return userId;
    }
}

The WatchlistMutationResolver class has three private read-only fields: _watchlistService, _movieService, and _userService. These fields are instances of IWatchlist, IMovie, and IUser interfaces, respectively, and they are initialized via dependency injection in the constructor.

The ToggleWatchlist method is an asynchronous method that toggles a movie in a user’s watchlist and then returns an updated list of movies in the watchlist. It’s decorated with the Authorize attribute, which means it requires the client to be authenticated to call this method. The GraphQLDescription attribute provides a description for this field in the GraphQL schema.

The ClearWatchlist method is another asynchronous method that deletes all items from a user’s watchlist and then returns the user’s ID. It’s also decorated with the Authorize and GraphQLDescription attributes.

Add a GraphQL server query to get similar movies

Add the following method to the MovieQueryResolver class.

[GraphQLDescription("Gets the list of movies which belongs to the same genre as the movie whose movieId is passed as parameter.")]
public async Task<List<Movie>> GetSimilarMovies(int movieId)
{
    return await _movieService.GetSimilarMovies(movieId);
}

This asynchronous method returns a list of Movie objects with the same genre as the given movie, identified by the movieId parameter.

Configure the Program.cs file

Since we have added new GraphQL resolvers, we need to register them in our middleware.

Update the following code example in the Program.cs file.

builder.Services.AddGraphQLServer()
       .AddAuthorization()
       .AddQueryType<MovieQueryResolver>()
       .AddTypeExtension<WatchlistQueryResolver>()
       .AddMutationType<MovieMutationResolver>()
       .AddTypeExtension<AuthMutationResolver>()
       .AddTypeExtension<WatchlistMutationResolver>()
       .AddFiltering()
       .AddErrorFilter(error =>
       {
          return error;
       });

We use the AddTypeExtension method to register new resolvers of the types WatchlistQueryResolver and WatchlistMutationResolver.

We will register the transient lifetime of the IWatchlist service using the following code.

builder.Services.AddTransient<IWatchlist, WatchlistDataAccessLayer>();

With the server configuration complete, we can now move to the client side of the app.

Add the GraphQL query

Add the following GraphQL queries to the src\app\GraphQL\query.ts file.

export const GET_SIMILAR_MOVIE = gql`
  query FetchSimilarMovies($movieId: Int!) {
    similarMovies(movieId: $movieId) {
      movieId
      title
      posterPath
      genre
      rating
      language
      duration
      overview
    }
  }
`;

export const GET_WATCHLIST = gql`
  query FetchWatchList($userId: Int!) {
    watchlist(userId: $userId) {
      movieId
      title
      posterPath
      genre
      rating
      language
      duration
    }
  }
`;

The code uses the gql tag from the graphql-tag library to define two GraphQL queries as described:

  • GET_SIMILAR_MOVIE: This query is named FetchSimilarMovies and it takes a single parameter, $movieId, of type Int!. The ‘!’ indicates that this parameter is required. The query fetches similar movies to the given movie ID, returning several fields for each similar movie, such as movieId, title, posterPath, genre, rating, language, duration, and overview.
  • GET_WATCHLIST: This query is named FetchWatchList and takes a single parameter, $userId, of type Int!. It fetches the watchlist for the given user ID and returning several fields for each movie in the watchlist, such as movieId, title, posterPath, genre, rating, language, and duration.

Add the GraphQL mutation

Add the following GraphQL mutation in the src\app\GraphQL\mutation.ts file.

export const TOGGLE_WATCHLIST = gql`
  mutation toggleUserWatchlist($userId: Int!, $movieId: Int!) {
    toggleWatchlist(userId: $userId, movieId: $movieId) {
      movieId
      title
      posterPath
      genre
      rating
      language
      duration
    }
  }
`;

export const CLEAR_WATCHLIST = gql`
  mutation clearWatchlist($userId: Int!) {
    clearWatchlist(userId: $userId)
  }
`;

The code uses the gql tag from the graphql-tag library to define two GraphQL mutations as described below:

  • TOGGLE_WATCHLIST: This mutation is named toggleUserWatchlist, and it takes two parameters, $userId and $movieId, of type Int!. The ‘!’ indicates that these parameters are required. The mutation toggles a movie’s watchlist status for a user. If the movie is already on the user’s watchlist, it will be removed. If it’s not, it will be added. The mutation returns several fields for the toggled movie: movieId, title, posterPath, genre, rating, language, and duration.
  • CLEAR_WATCHLIST: This mutation is named clearWatchlist and takes a single parameter, $userId, of type Int!. It clears the watchlist for the given user ID, removing all movies from it.

Create the client side model

Add the following type definitions to the movie.ts file under the src\app\models\movie.ts folder.

export type SimilarMovieType = {
  similarMovies: Movie[];
};

export type WatchlistType = {
  watchlist: Movie[];
};

export type ToggleWatchlistType = {
  toggleWatchlist: Movie[];
};

Create the required GraphQL services

Generate the fetch similar movies service

Run the following command in the ClientApp folder to generate a service file.

ng g s services\fetch-similar-movies

Add the following code to the fetch-similar-movies.service.ts file.

import { Injectable } from '@angular/core';
import { Query } from 'apollo-angular';
import { SimilarMovieType } from '../models/movie';
import { GET_SIMILAR_MOVIE } from '../GraphQL/query';

@Injectable({
  providedIn: 'root',
})
export class FetchSimilarMoviesService extends Query<SimilarMovieType> {
  document = GET_SIMILAR_MOVIE;
}

This service performs a GraphQL query to fetch a list of similar movies for a given movie ID.

Generate the fetch watchlist service

Run the following command to generate a new service file.

ng g s services\fetch-watchlist

Add the following code to the fetch-watchlist.service.ts file.

import { Injectable } from '@angular/core';
import { Query } from 'apollo-angular';
import { GET_WATCHLIST } from '../GraphQL/query';
import { WatchlistType } from '../models/movie';

@Injectable({
  providedIn: 'root',
})
export class FetchWatchlistService extends Query<WatchlistType> {
  document = GET_WATCHLIST;
}

This service performs a GraphQL query to fetch the watchlist for a logged-in user.

Generate the toggle watchlist service

Run the following command to generate a new service file.

ng g s services\toggle-watchlist

Add the following code to the toggle-watchlist.service.ts file.

import { Injectable } from '@angular/core';
import { Mutation } from 'apollo-angular';
import { TOGGLE_WATCHLIST } from '../GraphQL/mutation';
import { ToggleWatchlistType } from '../models/movie';

@Injectable({
  providedIn: 'root',
})
export class ToggleWatchlistService extends Mutation<ToggleWatchlistType> {
  document = TOGGLE_WATCHLIST;
}

This service performs a GraphQL mutation to toggle the addition and removal of movies from the watchlist.

Generate the clear watchlist service

Run the following command to generate a new service file.

ng g s services\clear-watchlist

Add the following code to the clear-watchlist.service.ts file.

import { Injectable } from '@angular/core';
import { CLEAR_WATCHLIST } from '../GraphQL/mutation';
import { Mutation } from 'apollo-angular';

@Injectable({
  providedIn: 'root',
})
export class ClearWatchlistService extends Mutation {
  document = CLEAR_WATCHLIST;
}

This service performs a GraphQL mutation to clear the watchlist, i.e., remove all the items from it.

The next step is to update the SubscriptionService class in the src\app\services\subscription.service.ts file by adding the following two properties.

export class SubscriptionService {
  // existing code.
  watchlistItemcount$ = new BehaviorSubject<number>(0);
  watchlistItem$ = new BehaviorSubject<Movie[]>([]);
}

Run the following command to generate a new service file.

ng g s services\watchlist

Add the following code to the watchlist.service.ts file.

import { Injectable } from '@angular/core';
import { SubscriptionService } from './subscription.service';
import { FetchWatchlistService } from './fetch-watchlist.service';
import { ToggleWatchlistService } from './toggle-watchlist.service';
import { map } from 'rxjs';
import { Movie } from '../models/movie';
import { ClearWatchlistService } from './clear-watchlist.service';

@Injectable({
  providedIn: 'root',
})
export class WatchlistService {
  constructor(
    private readonly subscriptionService: SubscriptionService,
    private readonly fetchWatchlistService: FetchWatchlistService,
    private readonly toggleWatchlistService: ToggleWatchlistService,
    private readonly clearWatchlistService: ClearWatchlistService
  ) {}

  getWatchlistItems(userId: Number) {
    return this.fetchWatchlistService
      .watch(
        {
          userId: Number(userId),
        },
        {
          fetchPolicy: 'network-only',
        }
      )
      .valueChanges.pipe(
        map((result) => {
          if (result.data) {
            this.setWatchlist(result.data?.watchlist);
          }
        })
      );
  }

  toggleWatchlistItem(userId: number, movieId: number) {
    return this.toggleWatchlistService
      .mutate(
        {
          userId: Number(userId),
          movieId: Number(movieId),
        },
        {
          fetchPolicy: 'network-only',
        }
      )
      .pipe(
        map((result) => {
          if (result.data) {
            this.setWatchlist(result.data?.toggleWatchlist);
          }
        })
      );
  }

  clearWatchlist(userId: number) {
    return this.clearWatchlistService
      .mutate(
        {
          userId: Number(userId),
        },
        {
          fetchPolicy: 'network-only',
        }
      )
      .pipe(
        map((result) => {
          if (result.data) {
            this.setWatchlist([]);
          }
        })
      );
  }

  private setWatchlist(watchList: Movie[]) {
    this.subscriptionService.watchlistItemcount$.next(watchList.length);
    this.subscriptionService.watchlistItem$.next(watchlist);
  }
}

The constructor of the WatchlistService class injects four services into the component: SubscriptionService, FetchWatchlistService, ToggleWatchlistService, and ClearWatchlistService. These services manage the user’s watchlist.

The getWatchlistItems method fetches the watchlist items for a user by calling the watch method on fetchWatchlistService with the userId and a fetch policy of network-only. The watch method returns an Observable that emits the result of the GraphQL query. The map operator extracts the watchlist from the result and calls setWatchlist.

The toggleWatchlistItem method toggles the watchlist status of a movie for a user by calling the mutate method on toggleWatchlistService with the userId and movieId and a fetch policy of network-only. The mutate method returns an Observable that emits the result of the GraphQL mutation. The map operator extracts the toggleWatchlist from the result and calls setWatchlist.

The clearWatchlist method clears the watchlist for a user by calling the mutate method on clearWatchlistService with the userId and a fetch policy of network-only. The mutate method returns an Observable that emits the result of the GraphQL mutation. The map operator is used to check if the data property of the result is true, and if so, call setWatchlist with an empty array.

The private method setWatchlist updates the watchlist in the subscriptionService by calling the next method on two Observables: watchlistItemcount$ and watchlistItem$. The next method pushes the new values (the length of the watchlist and the watchlist itself, respectively) to any subscribers of these Observables.

Create the AddToWatchlist component

Run the following command to create the AddToWatchlist component.

ng g c components\add-to-watchlist

Add the following code to the src\app\components\add-to-watchlist\add-to-watchlist.component.ts file.

import { Component, Input, OnChanges } from '@angular/core';
import { EMPTY, ReplaySubject, switchMap, takeUntil } from 'rxjs';
import { Movie } from 'src/app/models/movie';
import { SubscriptionService } from 'src/app/services/subscription.service';
import { WatchlistService } from 'src/app/services/watchlist.service';
import { ToastUtility } from '@syncfusion/ej2-notifications';

@Component({
  selector: 'app-add-to-watchlist',
  templateUrl: './add-to-watchlist.component.html',
  styleUrls: ['./add-to-watchlist.component.css'],
})

export class AddToWatchlistComponent implements OnChanges { @Input({ required: true }) movieId = 0; toggle = false; buttonText = ''; iconClass = 'e-zoom-in-2'; private destroyed$ = new ReplaySubject<void>(1); constructor( private readonly watchlistService: WatchlistService, private readonly subscriptionService: SubscriptionService ) {} ngOnChanges() { this.subscriptionService.watchlistItem$ .pipe(takeUntil(this.destroyed$)) .subscribe((movieData: Movie[]) => { this.setFavourite(movieData); this.setButtonText(); }); } toggleValue() { this.toggle = !this.toggle; this.setButtonText(); this.subscriptionService.userData$ .pipe( switchMap((user) => { const userId = user.userId; if (userId > 0) { return this.watchlistService.toggleWatchlistItem( userId, this.movieId ); } else { return EMPTY; } }), takeUntil(this.destroyed$) ) .subscribe({ next: () => { if (this.toggle) { ToastUtility.show({ content: 'Movie added to your Watchlist.', position: { X: 'Right', Y: 'Top' }, cssClass: 'e-toast-success', }); } else { ToastUtility.show({ content: 'Movie removed from your Watchlist.', position: { X: 'Right', Y: 'Top' }, }); } }, error: (error) => { console.error('Error occurred while setting the Watchlist : ', error); }, }); } private setFavourite(movieData: Movie[]) { const favouriteMovie = movieData.find((f) => f.movieId === this.movieId); if (favouriteMovie) { this.toggle = true; } else { this.toggle = false; } } private setButtonText() { if (this.toggle) { this.buttonText = 'Remove from Watchlist'; this.iconClass = 'e-zoom-out-2'; } else { this.buttonText = 'Add to Watchlist'; this.iconClass = 'e-zoom-in-2'; } } ngOnDestroy(): void { this.destroyed$.next(); this.destroyed$.complete(); } }

The AddToWatchlistComponent class implements the OnChanges method to handle changes to input properties. The constructor injects WatchlistService and SubscriptionService into the component. These are used to manage the user’s watchlist.

The movieId is an input property that represents the ID of the movie to add to the watchlist. It’s required and defaults to 0.

The ngOnChanges lifecycle hook is called when an input property changes. It subscribes to subscriptionService.watchlistItem$, and when it emits, it calls setFavourite and setButtonText.

The toggleValue method toggles the movie’s watchlist status. It subscribes to subscriptionService.userData$, gets the userId, and calls watchlistService.toggleWatchlistItem with the userId and movieId. If the user ID is not greater than 0, it returns EMPTY. A toast notification is shown when a movie is added or removed from the watchlist, and any errors are logged to the console.

The private method setFavourite sets the toggle value based on whether the movie is on the watchlist. The private method setButtonText sets the button text and icon class based on the toggle value.

Add the following code to the src\app\components\add-to-watchlist\add-to-watchlist.component.html file.

<button
  ejs-button
  class="full-width"
  [ngClass]="{ 'e-warning': toggle, 'e-success': !toggle }"
  iconCss="e-icons {{ iconClass }}"
  mat-raised-button
  (click)="toggleValue()"
>
  {{ buttonText }}
</button>

We have added a button that toggles a movie’s presence in a watchlist. The ejs-button attribute indicates that the button is a Syncfusion Button. The method toggleValue is invoked at the click of the button. This ngClass directive is used to apply the CSS classes dynamically based on the condition. If the toggle is true, the e-warning class is applied; if the toggle is false, the e-success class is applied.

Create the Watchlist component

Run the following command to create the Watchlist component.

ng g c components\watchlist

Add the following code to the src\app\components\watchlist\watchlist.component.ts file.

import { Component, OnDestroy } from '@angular/core';
import { EMPTY, ReplaySubject, switchMap, takeUntil } from 'rxjs';
import { SubscriptionService } from 'src/app/services/subscription.service';
import { WatchlistService } from 'src/app/services/watchlist.service';
import { ToastUtility } from '@syncfusion/ej2-notifications';

@Component({
  selector: 'app-watchlist',
  templateUrl: './watchlist.component.html',
  styleUrls: ['./watchlist.component.css'],
})
export class WatchlistComponent implements OnDestroy {
  private destroyed$ = new ReplaySubject<void>(1);
  watchlistItems$ = this.subscriptionService.watchlistItem$;

  constructor(
    private readonly watchlistService: WatchlistService,
    private readonly subscriptionService: SubscriptionService
  ) {}

  clearWatchlist() {
    this.subscriptionService.userData$
      .pipe(
        switchMap((user) => {
          const userId = user.userId;
          if (userId > 0) {
            return this.watchlistService.clearWatchlist(userId);
          } else {
            return EMPTY;
          }
        }),
        takeUntil(this.destroyed$)
      )
      .subscribe({
        next: () => {
          ToastUtility.show({
            content: 'Watchlist cleared!!!',
            position: { X: 'Right', Y: 'Top' },
          });
        },
        error: (error) => {
          console.error(
            'Error occurred while deleting the Watchlist : ',
            error
          );
        },
      });
  }

  ngOnDestroy(): void {
    this.destroyed$.next();
    this.destroyed$.complete();
  }
}

The constructor of the class WatchlistComponent injects two services into the component: WatchlistService and SubscriptionService. These services manage the user’s watchlist.

The property watchlistItems$ is an Observable that emits the watchlist items. It’s assigned the value of subscriptionService.watchlistItem$.

The clearWatchlist method is used to clear the watchlist for the current user by subscribing to subscriptionService.userData$, getting the userId, and calling watchlistService.clearWatchlist with the userId. If the user ID is not greater than 0, it returns EMPTY, an Observable that emits no items and immediately completes. The takeUntil operator automatically unsubscribes from the Observable when destroyed$ emits a value. A toast notification is shown when the watchlist is successfully cleared and any errors are logged in the console.

Add the following code to the src\app\components\watchlist\watchlist.component.html file.

<ng-container *ngIf="watchlistItems$ | async as watchlistItems">
    <div class="my-2">
      <div
        class="title-container p-2 d-flex align-items-center justify-content-between"
      >
        <h2 class="m-0">My Watchlist</h2>
        <div>
          <button ejs-button cssClass="e-danger" (click)="clearWatchlist()">
            Clear Watchlist
          </button>
        </div>
      </div>
      <ng-container *ngIf="watchlistItems.length > 0; else emptyWatchlist">
        <div class="e-card">
          <div class="e-card-content">
            <ejs-grid #grid [dataSource]="watchlistItems">
              <e-columns>
                <e-column headerText="Poster" width="150">
                  <ng-template #template let-movieData>
                    <ejs-tooltip id="tooltip" content="{{ movieData.title }}">
                      <img
                        class="my-2"
                        src="/Poster/{{ movieData.posterPath }}"
                      />
                    </ejs-tooltip>
                  </ng-template>
                </e-column>
                <e-column headerText="Title" width="150">
                  <ng-template #template let-movieData>
                    <a [routerLink]="['/movies/details/', movieData.movieId]">
                      {{ movieData.title }}
                    </a>
                  </ng-template>
                </e-column>
                <e-column field="genre" headerText="Genre" width="100"></e-column>
                <e-column
                  field="language"
                  headerText="Language"
                  width="100"
                ></e-column>
                <e-column headerText="Operation" width="150">
                  <ng-template #template let-movieData>
                    <ejs-tooltip content="Edit movie">
                      <app-add-to-watchlist
                        [movieId]="movieData.movieId"
                      ></app-add-to-watchlist>
                    </ejs-tooltip>
                  </ng-template>
                </e-column>
              </e-columns>
            </ejs-grid>
          </div>
        </div>
      </ng-container>
    </div>
  </ng-container>
  
  <ng-template #emptyWatchlist>
    <div class="e-card">
      <div class="e-card-content">
        <h2 class="m-2">Your watchlist is empty.</h2>
        <button
          ejs-button
          cssClass="e-link"
          iconCss="e-icons e-back e-medium"
          [routerLink]="['/']"
        >
          Back to Home
        </button>
      </div>
    </div>
  </ng-template>

The ngIf directive subscribes to the watchlistItems$ Observable and assigns the emitted value to the local variable watchlistItems. The content inside the ng-container is only rendered if watchlistItems$ emits a truthy value.

A Syncfusion Button with the e-danger CSS class is added. When clicked, it calls the clearWatchlist method from the component.

The dataSource input of the Syncfusion DataGrid component is bound to watchlistItems, which displays the items in the watchlist. The e-column elements define the grid’s columns. Each column has a headerText attribute that sets the column header, and some have a field attribute that binds the column to a property of the data items. Some columns also have an ng-template that customizes how the data is displayed.

The app-add-to-watchlist component’s movieId input is bound to movieData.movieId, so the component knows which movie to add to or remove from the watchlist.

When the watchlist is empty, the emptyWatchlist template is rendered. It displays a message and a button that navigates to the home page.

Create the similar-movies component

Run the next command to create the SimilarMovies component.

ng g c components\similar-movies

Add the following code in the src\app\components\similar-movies\similar-movies.component.ts file.

import { Component } from '@angular/core';
import { ActivatedRoute, Params } from '@angular/router';
import { EMPTY, map, switchMap } from 'rxjs';
import { FetchSimilarMoviesService } from 'src/app/services/fetch-similar-movies.service';

@Component({
  selector: 'app-similar-movies',
  templateUrl: './similar-movies.component.html',
  styleUrls: ['./similar-movies.component.css'],
})
export class SimilarMoviesComponent {
  readonly similarMovies$ = this.activatedRoute.paramMap.pipe(
    switchMap((params: Params) => {
      const selectedMovieId = Number(params.get('movieId'));
      if (selectedMovieId > 0) {
        return this.fetchSimilarMoviesService
          .watch(
            {
              movieId: Number(selectedMovieId),
            },
            {
              fetchPolicy: 'network-only',
            }
          )
          .valueChanges.pipe(map((result) => result?.data?.similarMovies));
      } else {
        return EMPTY;
      }
    })
  );
  constructor(
    private readonly activatedRoute: ActivatedRoute,
    private readonly fetchSimilarMoviesService: FetchSimilarMoviesService
  ) {}
}

The SimilarMoviesComponent class contains an observable similarMovies$, which emits similar movies for a selected movie. It uses the paramMap Observable from ActivatedRoute to get the movieId parameter from the route. It then uses the switchMap method to call FetchSimilarMoviesService.watch with the movieId and fetch the policy network-only. The watch method returns an Observable that emits the result of the GraphQL query. The map operator is used to extract the similarMovies from the result. If movieId is not greater than 0, it returns EMPTY, an Observable that emits no items and immediately completes.

Add the following code to the src\app\components\similar-movies\similar-movies.component.html file.

<ng-container *ngIf="similarMovies$ | async as movies">
  <div class="row my-4 g-0">
    <div class="col-12 title-container p-2">
      <h2 class="m-0">Similar Movies</h2>
    </div>
    <div class="e-card">
      <div class="e-card-content row p-3">
        <div class="d-flex justify-content-start flex-wrap">
          <div *ngFor="let movie of movies" class="p-1">
            <app-movie-card [movie]="movie"></app-movie-card>
          </div>
        </div>
      </div>
    </div>
  </div>
</ng-container>

The ngIf directive subscribes to the similarMovies$ Observable and assigns the emitted value to a local variable movies. The async pipe subscribes to an Observable and automatically updates the view whenever a new value is emitted. If similarMovies$ does not emit a value (i.e., null or undefined), the ng-container and its contents will not be rendered.

The ngFor directive loops over the movies array. For each movie, it creates a new div and assigns the movie to the local variable movie.

We will then display the movie cards using the <app-movie-card> component, binding the movie input property of app-movie-card to the movie variable.

Create the PageNotFound component

Run the following command to create the PageNotFound component.

ng g c components\page-not-found

Add the following code to the src\app\components\page-not-found\page-not-found.component.html file.

<div class="e-card">
  <div class="e-card-content">
    <h2 class="m-2">The resource you are looking for is not found.</h2>
    <button
      ejs-button
      cssClass="e-link"
      iconCss="e-icons e-back e-medium"
      [routerLink]="['/']"
    >
      Back to Home
    </button>
  </div>
</div>

This page will be rendered when the user tries to access a route that does not exist. We display a message and a button to redirect the user to the home page.

Update the MovieDetails component

Update the MovieDetailsComponent class in the src\app\components\movie-details\movie-details.component.ts file.

import { Component } from '@angular/core';
import { ActivatedRoute, Params } from '@angular/router';

export class MovieDetailsComponent {
  // Existing code.
  
  userData$ = this.subscriptionService.userData$;

  constructor(
    // Existing service injection.
    private readonly subscriptionService: SubscriptionService
  ) {}
}

Add the selectors for the SimilarMoviesComponent and AddToWatchlistComponent inside the template of the MovieDetailsComponent.

Add the selector for AddToWatchlistComponent just after the movie cover image’s <div> element.

<ng-container *ngIf="movieDetails$ | async as movie; else noMovieFound">

<!-- Existing code -->

<div *ngIf="userData$ | async as user" class="mt-2 image-width">
  <app-add-to-watchlist
    *ngIf="user.isLoggedIn"
    [movieId]="movie.movieId"
  ></app-add-to-watchlist>
</div>

<!-- Existing code -->

</ng-container>

Insert the selector for the SimilarMoviesComponent just before closing the <ng-container> element.

<ng-container *ngIf="movieDetails$ | async as movie; else noMovieFound">

<!-- Existing code -->

<app-similar-movies></app-similar-movies>

</ng-container>

Configure app routing

Open the src\app\app-routing.module.ts file and add the route for the newly added components under the appRoutes array.

const appRoutes: Routes = [
  // Existing routes.
  {
    path: 'watchlist',
    component: WatchlistComponent,
    canActivate: [authGuard],
  },
  { path: '**', component: PageNotFoundComponent },

];

The route configuration maps the path /watchlist to the WatchlistComponent. The canActivate option means that the authGuard needs to return true for the route to be activated.

At the end of the array, we have added the wildcard route that maps any path not matched by the previous routes to the PageNotFoundComponent.

Note: The order in which we define the application’s routes is very important. The router uses a first-match wins strategy when matching routes. Therefore, more specific routes should be placed above less specific routes. The wildcard route should be placed at the end because it matches every URL. It should be selected only if no other routes match first.

Update the nav-bar component

Update the NavBarComponent class under the src\app\components\nav-bar\nav-bar.component.ts file.

export class NavBarComponent implements OnInit, OnDestroy {
  // Existing code
  readonly watchlistItemcount$ = this.subscriptionService.watchlistItemcount$;

  constructor(
    // Existing service injection
    private readonly watchlistService: WatchlistService
  ) {}

  ngOnInit(): void {
    this.subscriptionService.userData$
      .pipe(
        switchMap((user: User) => {
          this.authenticatedUser = user;
          if (this.authenticatedUser.userId > 0) {
            return this.watchlistService.getWatchlistItems(
              this.authenticatedUser.userId
            );
          } else {
            return EMPTY;
          }
        }),
        takeUntil(this.destroyed$)
      )
      .subscribe({
        error: (error) => {
          console.error('Error occurred while setting the Watchlist : ', error);
        },
      });
  }
  
  // Existing methods
}

In this update, we subscribe to the userData$ Observable from the subscription service. The RxJS operator switchMap cancels the previous Observable and switches to a new one. In this case, it takes the emitted User object, assigns it to this.authenticatedUser, and then returns a new Observable based on the userId. If userId is greater than 0, it calls the getWatchlistItems method from the WatchlistService to get the watchlist items for the user. If userId is not greater than 0, it returns EMPTY, an Observable that emits no items and immediately completes. If an error occurs, it logs the error message to the console.

The next step is to update the src\app\components\nav-bar\nav-bar.component.html file by adding a button to navigate to the watchlist page. Add the following HTML template just before the code for the Login button.

<button
    ejs-button
    [isToggle]="true"
    cssClass="e-inherit e-round e-small"
    *ngIf="authenticatedUser.isLoggedIn"
    [routerLink]="['/watchlist']"
    iconCss="e-icons e-bookmark e-medium"
    class="watchlist-btn">
 <span
      class="e-badge e-badge-warning e-badge-notification e-badge-overlap e-badge-circle">{{ watchlistItemcount$ | async }}</span>
</button>

This button will render only when the user is authenticated. It will also display a badge to show the number of items on the watchlist. When the button is clicked, it navigates to the /watchlist route.

Set authorization header for API requests

Since we have added authorization checks on the GraphQL server, we need to send the authorization bearer token with each API request. The Bearer token is set to authenticate the client with the GraphQL server. When a client sends a request to a server, the server needs a way to verify the client’s identity. This is especially important for operations requiring certain permissions, such as fetching sensitive or modifying data.

Update the createApollo function in the src\app\graphql.module.ts file.

// Existing code.
export function createApollo(httpLink: HttpLink): ApolloClientOptions<any> {
  const auth = setContext(() => {
    const headerToken = localStorage.getItem('authToken');

    if (headerToken === null) {
      return {};
    } else {
      return {
        headers: {
          Authorization: `Bearer ${headerToken}`,
        },
      };
    }
  });

  const link = ApolloLink.from([auth, httpLink.create({ uri })]);
  const cache = new InMemoryCache();

  return {
    link,
    cache,
    connectToDevTools: true,
  };
}
// Existing code.

This function takes an HttpLink object as a parameter and returns an ApolloClientOptions object. HttpLink is used to connect to the GraphQL server. The setContext function creates middleware that can modify requests before they are sent. The bearer token is retrieved from local storage and added to the Authorization header of each request. An empty object is returned if the authentication token is null, meaning no headers are added to the request. If the token is not null, an object with an Authorization header is returned. An Apollo Link is created that first applies the auth link (which adds the Authorization header) and then the httpLink (which sends the request to the server).

The term bearer in the bearer token specifies the type of authentication. A bearer token means the bearer of this token is authorized, i.e., whoever presents this token has the right to access the data. This is a common pattern in web development, especially when working with APIs that require authentication.

Execution demo

After executing the previous code examples, we will get output like in the following image.

GitHub resource

For more details, refer to the complete source code on GitHub.

Harness the power of Syncfusion’s feature-rich and powerful Angular UI components.

Summary

Thank you for reading this article. In it, we learned how to implement the watchlist feature in our application, which allows the user to add or remove movies from the watchlist. We also added a feature to display similar movies when users visit the movie details page.

In the next and final article of this series, we will learn how to deploy this application to the IIS and Azure App Service.

If you are new to our platform, we extend an invitation to explore our Syncfusion Angular components with the convenience of a free trial. This trial allows you to experience the full potential of our components to enhance your app’s user interface and functionality.

Our dedicated support system is readily available if you need guidance or have any questions. Contact us through our support forumsupport portal, or feedback portal. Your success is our priority, and we’re always delighted to assist you on your development journey!

Related blogs

Ankit Sharma

Ankit Sharma is an author, a speaker, and a passionate programmer. He is a Google Developer Expert (GDE) for Angular and Microsoft’s Most Valuable Professional (MVP). Currently, he works in Cisco Systems as a software development engineer.