Реализация поиска с помощью ngrx / store angular 2

Я пытаюсь реализовать функцию поиска для приложения, написанного на angular 4. Это в основном для таблицы, которая показывает много данных. Я также добавил магазин ngrx. Как правильно реализовать поиск приложения с магазином? В настоящее время я очищаю хранилище каждый раз для поискового запроса, а затем заполняю его данными, полученными в результате асинхронного вызова серверной части. Тогда я показываю эти данные в HTML. Асинхронный вызов выполняется из файла эффектов.


person Arjun Singh    schedule 13.09.2017    source источник
comment
Вы можете уточнить? Будет ли поиск запускать асинхронный вызов или это больше похоже на функцию фильтрации, когда поисковый запрос применяется только к полученному набору данных?   -  person amu    schedule 29.09.2017
comment
Да, поиск запускает асинхронный вызов.   -  person Arjun Singh    schedule 29.09.2017


Ответы (3)


Недавно я реализовал функцию поиска с помощью Angular 4 и @ngrx. Я сделал это так, чтобы отправить действие EXECUTE_SEARCH, чтобы установить строку запроса в ваш магазин и вызвать эффект. Эффект вызвал асинхронный вызов. Когда асинхронный вызов вернулся, я отправил либо действие FETCH_SUCCESSFUL, либо действие FETCH_FAILURE в зависимости от результата. В случае успеха выставляю результат в своем магазине.

Когда вы очищаете результат в вашем хранилище, действительно зависит от желаемого поведения. В моем проекте я очистил результат на FETCH_SUCCESSFUL, заменив старый результат. В других случаях использования может быть разумным очистить результат из хранилища при выполнении нового поиска (в редукторе EXECUTE_SEARCH).

person amu    schedule 29.09.2017

Это старый вопрос, но я думаю, что он заслуживает более конкретного примера.

Поскольку каждый поиск уникален, я также очищаю результаты. Однако, поскольку список результатов может быть длинным, и я не хочу отображать их все, я загружаю все результаты (увенчанные достойным значением, настроенным в API), но отображаю их с помощью разбивки на страницы.

Следующие использовали Angular 7 + ngrx / store.

Действия

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;

Эффекты

Есть только один эффект, который загружает данные при необходимости. Даже если я отображаю данные с использованием разбивки на страницы, результаты поиска сразу же получаются с сервера.

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

}

Редукторы

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

Селекторы

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

Источник данных

Это необходимо, потому что я работаю с таблицей материалов и пагинатором. Он также имеет дело с разбивкой на страницы: таблица (на самом деле источник данных) запрашивает страницу, но эффект загрузит все, если необходимо, и вернет эту страницу. Конечно, последующие страницы не будут отправляться на сервер для получения дополнительных данных.

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

Код компонента

Компонент результатов поиска получил условия поиска в качестве параметров запроса и обращается к источнику данных для загрузки соответствующей страницы.

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) + "...";
  }
}

Разметка компонента

<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>

Здесь много кода, и я думаю, что его можно улучшить, но это хорошее начало, чтобы иметь идиоматический код ngrx для поиска вещей в вашем SPA.

person Alexei - check Codidact    schedule 10.07.2019

Что ж, так как я долгое время не находил ответа на этот вопрос, я решил сохранить все данные, поступающие из серверной части, а затем искать данные следующим образом:

Я реализовал эффект поиска, который запускал асинхронный вызов серверной части. Из серверной части я возвращал как результаты поиска, так и их идентификаторы. Этот эффект после получения данных запускает действие завершения поиска. Затем в этом действии редуктора я использовал для хранения идентификаторов результатов в моем состоянии с именем searchIds, и я создал состояние с объектами имени, которое в основном было картой данных с идентификаторами в качестве ключа.

Данные, которые будут получены от серверной части, будут отфильтрованы, чтобы проверить, есть ли они уже в магазине или нет, если нет, то они были добавлены к объектам. После этого я подписался на селектор, который в основном будет искать ключи, присутствующие в searchIds, и возвращать мне только эти данные из сущностей. Поскольку это была карта, уже имеющая идентификаторы в качестве ключей, было очень эффективно выполнять поиск на основе searchIds, и мне также не нужно было очищать данные, которые у меня уже были. Это, в свою очередь, поддерживало истинную цель @ ngrx / store - кэшировать любые данные, которые я получал.

person Arjun Singh    schedule 29.09.2017