Implementing search with ngrx/store angular 2

4.4k views Asked by At

I am trying to implement a search feature for an application written in angular 4. It is basically for a table which is showing lots of data. I have also added ngrx store. What is the right way of implementing a search for an application with the store? Currently, I am clearing the store every single time, for the search query and, then populating it with the data I received from the asynchronous call to the back end. I am showing this data in the HTML then. The asynchronous call is made from an effects file.

3

There are 3 answers

0
amu On

I recently implemented a search feature with Angular 4 and @ngrx. The way I did it was to dispatch a EXECUTE_SEARCH action to set the query string to your store and trigger an effect. The effect triggered the asynchronous call. When the async call returned I dispatched either a FETCH_SUCCESSFUL action or a FETCH_FAILURE action based on the result. If successful, I set the result in my store.

When you clear the result in you store, really depends on the desired behaviour. I my project, I cleared the result on FETCH_SUCCESSFUL, replacing the old result. In other use cases it might be reasonable to clear the result from store when you execute a new search (in the EXECUTE_SEARCH reducer).

0
Arjun Singh On

Well, Since I did not find an answer for this question for a longtime I took an approach of saving whatever data was coming from the back end and then searching for the data in following way:

I implemented a search effect which would fire an asynchronous call to the back end. From the back end I was returning both the search results as well as their ids. This effect after receiving the data would fire the search complete action. Then in this reducer action I used to store the ids of the results in my state with a name searchIds and I had created a state with name entities which was basically a map of data with the ids as the key.

The data that would be received from the back end would filtered to check if it is already present in the store or not if not then it was appended to the entities. After that I had subscribed to a selector which would basically look up for the keys present in searchIds and return me only that data from entities. Since it was a map already having ids as the keys it was very efficient to search based on the searchIds and I also did not have to clear the data which I already had. This in turn maintained the true purpose of a @ngrx/store to cache whatever data I was receiving.

0
Alexei - check Codidact On

This is an old question, but I think it deserves a more concrete example.

Since each search is basically unique, I am also clearing the results. However, since the results list might be long and I do not want to display them all, I load all the results (topped by a decent value configured in the API), but display them using pagination.

The following used Angular 7 + ngrx/store.

Actions

import { Action } from "@ngrx/store";
import { PostsSearchResult } from "../models/posts-search-result";

export enum PostsSearchActionType {
    PostsSearchResultRequested = "[View Search Results] Search Results Requested",
    PostsSearchResultLoaded = "[Search Results API] Search Results Loaded",

    PostsSearchResultsClear = "[View Search Results Page] Search Results Page Clear",
    PostsSearchResultsPageRequested = "[View Search Results Page] Search Results Page Requested",
    PostsSearchResultsPageLoaded = "[Search Results API] Search Results Page Loaded",
    PostsSearchResultsPageCancelled = "[Search Results API] Search Results Page Cancelled",
}

export class PostsSearchResultsClearAction implements Action {
  readonly type = PostsSearchActionType.PostsSearchResultsClear;

  constructor() {
  }
}

export class PostsSearchPageRequestedAction implements Action {
  readonly type = PostsSearchActionType.PostsSearchResultsPageRequested;

  constructor(public payload: { searchText: string }) {
  }
}

export class PostsSearchRequestedAction implements Action {
    readonly type = PostsSearchActionType.PostsSearchResultRequested;

    constructor(public payload: { searchText: string }) {
    }
}

export class PostsSearchLoadedAction implements Action {
    readonly type = PostsSearchActionType.PostsSearchResultLoaded;

    constructor(public payload: { results: PostsSearchResult[] }) {
    }
}

export class PostsSearchResultsPageLoadedAction implements Action {
  readonly type = PostsSearchActionType.PostsSearchResultsPageLoaded;

  constructor(public payload: { searchResults: PostsSearchResult[] }) {
  }
}

export class PostsSearchResultsPageCancelledAction implements Action {
  readonly type = PostsSearchActionType.PostsSearchResultsPageCancelled;
}

export type PostsSearchAction =
    PostsSearchResultsClearAction |
    PostsSearchRequestedAction |
    PostsSearchLoadedAction |
    PostsSearchPageRequestedAction |
    PostsSearchResultsPageLoadedAction |
    PostsSearchResultsPageCancelledAction;

Effects

There is only one effect that loads data if needed. Even if I display the data using pagination, the search results are fetched from server all at once.

import { Injectable } from "@angular/core";
import { Actions, Effect, ofType } from "@ngrx/effects";
import { Store, select } from "@ngrx/store";
import { AppState } from "src/app/reducers";

import {  mergeMap, map, catchError, tap, switchMap } from "rxjs/operators";
import { of } from "rxjs";
import { PostsService } from "../services/posts.service";
// tslint:disable-next-line:max-line-length
import { PostsSearchRequestedAction, PostsSearchActionType, PostsSearchLoadedAction, PostsSearchPageRequestedAction, PostsSearchResultsPageCancelledAction, PostsSearchResultsPageLoadedAction } from "./posts-search.actions";
import { PostsSearchResult } from "../models/posts-search-result";
import { LoggingService } from "src/app/custom-core/general/logging-service";
import { LoadingStartedAction } from "src/app/custom-core/loading/loading.actions";
import { LoadingEndedAction } from "../../custom-core/loading/loading.actions";

@Injectable()
export class PostsSearchEffects {

  constructor(private actions$: Actions, private postsService: PostsService, private store: Store<AppState>,
    private logger: LoggingService) {
  }

  @Effect()
  loadPostsSearchResults$ = this.actions$.pipe(
    ofType<PostsSearchRequestedAction>(PostsSearchActionType.PostsSearchResultRequested),
    mergeMap((action: PostsSearchRequestedAction) => this.postsService.searchPosts(action.payload.searchText)),
    map((results: PostsSearchResult[]) => {
      return new PostsSearchLoadedAction({ results: results });
    })
  );

  @Effect()
  loadSearchResultsPage$ = this.actions$.pipe(
      ofType<PostsSearchPageRequestedAction>(PostsSearchActionType.PostsSearchResultsPageRequested),

    switchMap(({ payload }) => {
      this.logger.logTrace("loadSearchResultsPage$ effect triggered for type PostsSearchResultsPageRequested");

      this.store.dispatch(new LoadingStartedAction({ message: "Searching ..."}));

      return this.postsService.searchPosts(payload.searchText).pipe(
        tap(_ => this.store.dispatch(new LoadingEndedAction())),
        catchError(err => {
          this.store.dispatch(new LoadingEndedAction());
          this.logger.logErrorMessage("Error loading search results: " + err);

          this.store.dispatch(new PostsSearchResultsPageCancelledAction());
          return of(<PostsSearchResult[]>[]);
        })
      );
    }),
      map(searchResults => {
        // console.log("loadSearchResultsPage$ effect searchResults: ", searchResults);
      const ret = new PostsSearchResultsPageLoadedAction({ searchResults });
      this.logger.logTrace("loadSearchResultsPage$ effect PostsSearchResultsPageLoadedAction: ", ret);
      return ret;
    })
  );

}

Reducers

These handle the dispatched actions. Each search will trigger a clear of existing information. However, each page request will used the already loaded information.

import { EntityState, EntityAdapter, createEntityAdapter } from "@ngrx/entity";
import { PostsSearchResult } from "../models/posts-search-result";
import { PostsSearchAction, PostsSearchActionType } from "./posts-search.actions";


export interface PostsSearchListState extends EntityState<PostsSearchResult> {
}

export const postsSearchAdapter: EntityAdapter<PostsSearchResult> = createEntityAdapter<PostsSearchResult>({
  selectId: r => `${r.questionId}_${r.answerId}`
});

export const initialPostsSearchListState: PostsSearchListState = postsSearchAdapter.getInitialState({
});

export function postsSearchReducer(state = initialPostsSearchListState, action: PostsSearchAction): PostsSearchListState {

  switch (action.type) {

    case PostsSearchActionType.PostsSearchResultsClear:
      console.log("PostsSearchActionType.PostsSearchResultsClear called");
      return postsSearchAdapter.removeAll(state);

    case PostsSearchActionType.PostsSearchResultsPageRequested:
      return state;

    case PostsSearchActionType.PostsSearchResultsPageLoaded:
      console.log("PostsSearchActionType.PostsSearchResultsPageLoaded triggered");
      return postsSearchAdapter.addMany(action.payload.searchResults, state);

    case PostsSearchActionType.PostsSearchResultsPageCancelled:
      return state;

    default: {
      return state;
    }
  }
}

export const postsSearchSelectors = postsSearchAdapter.getSelectors();

Selectors

import { createFeatureSelector, createSelector } from "@ngrx/store";
import { PostsSearchListState, postsSearchSelectors } from "./posts-search.reducers";
import { Features } from "../../reducers/constants";
import { PageQuery } from "src/app/custom-core/models/page-query";

export const selectPostsSearchState = createFeatureSelector<PostsSearchListState>(Features.PostsSearchResults);

export const selectAllPostsSearchResults = createSelector(selectPostsSearchState, postsSearchSelectors.selectAll);

export const selectSearchResultsPage = (page: PageQuery) => createSelector(
  selectAllPostsSearchResults,
  allResults => {
    const startIndex = page.pageIndex * page.pageSize;
    const pageEnd = startIndex + page.pageSize;
    return allResults
      .slice(startIndex, pageEnd);
  }
);

export const selectSearchResultsCount = createSelector(
  selectAllPostsSearchResults,
  allResults => allResults.length
);

Data source

This is required because I am working with a Material table and a paginator. It also kind of deals with pagination: the table (datasource actually) requests a page, but the effect will load everything if needed and return that page. Of course, subsequent pages will not go to server to fetch any more data.

import {CollectionViewer, DataSource} from "@angular/cdk/collections";
import {Observable, BehaviorSubject, of, Subscription} from "rxjs";
import {catchError, tap, take} from "rxjs/operators";
import { AppState } from "../../reducers";
import { Store, select } from "@ngrx/store";
import { PageQuery } from "src/app/custom-core/models/page-query";
import { LoggingService } from "../../custom-core/general/logging-service";
import { PostsSearchResult } from "../models/posts-search-result";
import { selectSearchResultsPage } from "../store/posts-search.selectors";
import { PostsSearchPageRequestedAction } from "../store/posts-search.actions";


export class SearchResultsDataSource implements DataSource<PostsSearchResult> {

 public readonly searchResultSubject = new BehaviorSubject<PostsSearchResult[]>([]);
 private searchSubscription: Subscription;

 constructor(private store: Store<AppState>, private logger: LoggingService) {
 }

 loadSearchResults(page: PageQuery, searchText: string) {

   this.logger.logTrace("SearchResultsDataSource.loadSearchResults started for page ", page, searchText);

   this.searchSubscription = this.store.pipe(
     select(selectSearchResultsPage(page)),
     tap(results => {
       // this.logger.logTrace("SearchResultsDataSource.loadSearchResults results ", results);

       if (results && results.length > 0) {
         this.logger.logTrace("SearchResultsDataSource.loadSearchResults page already in store ", results);
         this.searchResultSubject.next(results);
       } else {
         this.logger.logTrace("SearchResultsDataSource.loadSearchResults page not in store and dispatching request ", page);
         this.store.dispatch(new PostsSearchPageRequestedAction({ searchText: searchText}));
       }
     }),
     catchError(err => {
       this.logger.logTrace("loadSearchResults failed: ", err);
       return of([]);
     })
   )
   .subscribe();
 }

 connect(collectionViewer: CollectionViewer): Observable<PostsSearchResult[]> {
   this.logger.logTrace("SearchResultsDataSource: connecting data source");
   return this.searchResultSubject.asObservable();
 }

 disconnect(collectionViewer: CollectionViewer): void {
   console.log("SearchResultsDataSource: disconnect");
   this.searchResultSubject.complete();
 }
}

The component code

The search results component received the search terms as query parameters and turns to datasource to load the corresponding page.

import { Component, OnInit, ViewChild, OnDestroy, AfterViewInit } from "@angular/core";
import { Store, select } from "@ngrx/store";
import { AppState } from "src/app/reducers";
import { PostsSearchResultsClearAction } from "../../store/posts-search.actions";
import { ActivatedRoute, Router, ParamMap } from "@angular/router";
import { tap, map } from "rxjs/operators";
import { environment } from "../../../../environments/environment";
import { MatPaginator } from "@angular/material";
import { SearchResultsDataSource } from "../../services/search-results.datasource";
import { LoggingService } from "src/app/custom-core/general/logging-service";
import { PageQuery } from "src/app/custom-core/models/page-query";
import { Subscription, Observable } from "rxjs";
import { selectSearchResultsCount, selectAllPostsSearchResults } from "../../store/posts-search.selectors";

@Component({
  // tslint:disable-next-line:component-selector
  selector: "posts-search-results",
  templateUrl: "./posts-search-results.component.html",
  styleUrls: ["./posts-search-results.component.css"]
})
export class PostsSearchResultsComponent implements OnInit, OnDestroy, AfterViewInit {

  appEnvironment = environment;

  searchResultCount$: Observable<number>;

  dataSource: SearchResultsDataSource;
  displayedColumns = ["scores", "searchResult", "user"];
  searchText: string;
  searchSubscription: Subscription;

  @ViewChild(MatPaginator) paginator: MatPaginator;

  constructor(private store: Store<AppState>,
      private route: ActivatedRoute,
      private logger: LoggingService) {

    console.log("PostsSearchResultsComponent constructor");
  }

  ngOnInit() {
    console.log("PostsSearchResultsComponent ngOnInit");

    this.dataSource = new SearchResultsDataSource(this.store, this.logger);
    const initialPage: PageQuery = {
      pageIndex: 0,
      pageSize: 10
    };

    // request search results based on search query text
    this.searchSubscription = this.route.paramMap.pipe(
      tap((params: ParamMap) => {
        this.store.dispatch(new PostsSearchResultsClearAction());

        this.searchText = <string>params.get("searchText");
        console.log("Started loading search result with text", this.searchText);
        this.dataSource.loadSearchResults(initialPage, this.searchText);

      })
    ).subscribe();

    // this does not work due to type mismatch
    // Type 'Observable<MemoizedSelector<object, number>>' is not assignable to type 'Observable<number>'.
    // Type 'MemoizedSelector<object, number>' is not assignable to type 'number'.
    this.searchResultCount$ = this.store.pipe(
      select(selectSearchResultsCount));
  }

  ngOnDestroy(): void {
    console.log("PostsSearchResultsComponent ngOnDestroy called");
    if (this.searchSubscription) {
      this.searchSubscription.unsubscribe();
    }
  }

  loadQuestionsPage() {

   const newPage: PageQuery = {
     pageIndex: this.paginator.pageIndex,
     pageSize: this.paginator.pageSize
   };

   this.logger.logTrace("Loading questions for page: ", newPage);
   this.dataSource.loadSearchResults(newPage, this.searchText);
  }

  ngAfterViewInit() {

   this.paginator.page.pipe(
     tap(() => this.loadQuestionsPage())
   )
     .subscribe();
  }

  // TODO: move to a generic place
  getTrimmedText(text: string) {
    const size = 200;
    if (!text || text.length <= size) {
      return text;
    }

    return text.substring(0, size) + "...";
  }
}

The component markup

<h2>{{searchResultCount$ | async}} search results for <i>{{searchText}} </i></h2>

<mat-table [dataSource]="dataSource">
  <ng-container matColumnDef="scores">
    <mat-header-cell *matHeaderCellDef></mat-header-cell>
    <mat-cell *matCellDef="let result">
      <div class="question-score-box small-font">
        {{result.votes}}<br /><span class="small-font">score</span>
      </div>
      <div [ngClass]="{'answer-count-box': true, 'answer-accepted': result.isAnswered}" *ngIf="result.postType == 'question'">
        {{result.answerCount}}<br /><span class="small-font" *ngIf="result.answerCount == 1">answer</span><span class="small-font" *ngIf="result.answerCount != 1">answers</span>
      </div>
    </mat-cell>
  </ng-container>

  <ng-container matColumnDef="searchResult">
    <mat-header-cell *matHeaderCellDef></mat-header-cell>
    <mat-cell *matCellDef="let result">
      <a [routerLink]="['/posts', result.questionId]" [routerLinkActive]="['link-active']" id="questionView"
         [innerHTML]="'Q: ' + result.title" *ngIf="result.postType == 'question'">
      </a>
      <a [routerLink]="['/posts', result.questionId]" [routerLinkActive]="['link-active']" id="questionView"
         [innerHTML]="'A: ' + result.title" *ngIf="result.postType == 'answer'">
      </a>
      <span class="medium-font">{{getTrimmedText(result.body)}}</span>
    </mat-cell>
  </ng-container>

  <ng-container matColumnDef="user">
    <mat-header-cell *matHeaderCellDef></mat-header-cell>
    <mat-cell *matCellDef="let result">
      <div class="q-list-user-details">
        <span class="half-transparency">
          {{result.postType == 'question' ? 'Asked' : 'Added'}} on {{result.createDateTime | date: 'mediumDate'}}
          <br />
        </span>

        <a [routerLink]="['/users', result.creatorSoUserId]" [routerLinkActive]="['link-active']" id="addedByView">
          {{result.creatorName}}
        </a>
      </div>
    </mat-cell>
  </ng-container>

  <mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>

  <mat-row *matRowDef="let row; columns: displayedColumns"></mat-row>
</mat-table>

<mat-paginator #paginator
               [length]="searchResultCount$ | async"
               [pageIndex]="0"
               [pageSize]="10"
               [pageSizeOptions]="[5, 10, 25, 100]">
</mat-paginator>

<!-- <hr/> -->
<div *ngIf="!appEnvironment.production">
  {{(dataSource?.searchResultSubject | async) | json}}
</div>

There is a lot of code and I think it can be improved, but it is a good start to have a idiomatic ngrx code for searching stuff in your SPA.