Я пытаюсь реализовать функцию поиска для приложения, написанного на angular 4. Это в основном для таблицы, которая показывает много данных. Я также добавил магазин ngrx. Как правильно реализовать поиск приложения с магазином? В настоящее время я очищаю хранилище каждый раз для поискового запроса, а затем заполняю его данными, полученными в результате асинхронного вызова серверной части. Тогда я показываю эти данные в HTML. Асинхронный вызов выполняется из файла эффектов.
Реализация поиска с помощью ngrx / store angular 2
Ответы (3)
Недавно я реализовал функцию поиска с помощью Angular 4 и @ngrx. Я сделал это так, чтобы отправить действие EXECUTE_SEARCH, чтобы установить строку запроса в ваш магазин и вызвать эффект. Эффект вызвал асинхронный вызов. Когда асинхронный вызов вернулся, я отправил либо действие FETCH_SUCCESSFUL, либо действие FETCH_FAILURE в зависимости от результата. В случае успеха выставляю результат в своем магазине.
Когда вы очищаете результат в вашем хранилище, действительно зависит от желаемого поведения. В моем проекте я очистил результат на FETCH_SUCCESSFUL, заменив старый результат. В других случаях использования может быть разумным очистить результат из хранилища при выполнении нового поиска (в редукторе EXECUTE_SEARCH).
Это старый вопрос, но я думаю, что он заслуживает более конкретного примера.
Поскольку каждый поиск уникален, я также очищаю результаты. Однако, поскольку список результатов может быть длинным, и я не хочу отображать их все, я загружаю все результаты (увенчанные достойным значением, настроенным в 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.
Что ж, так как я долгое время не находил ответа на этот вопрос, я решил сохранить все данные, поступающие из серверной части, а затем искать данные следующим образом:
Я реализовал эффект поиска, который запускал асинхронный вызов серверной части. Из серверной части я возвращал как результаты поиска, так и их идентификаторы. Этот эффект после получения данных запускает действие завершения поиска. Затем в этом действии редуктора я использовал для хранения идентификаторов результатов в моем состоянии с именем searchIds, и я создал состояние с объектами имени, которое в основном было картой данных с идентификаторами в качестве ключа.
Данные, которые будут получены от серверной части, будут отфильтрованы, чтобы проверить, есть ли они уже в магазине или нет, если нет, то они были добавлены к объектам. После этого я подписался на селектор, который в основном будет искать ключи, присутствующие в searchIds, и возвращать мне только эти данные из сущностей. Поскольку это была карта, уже имеющая идентификаторы в качестве ключей, было очень эффективно выполнять поиск на основе searchIds, и мне также не нужно было очищать данные, которые у меня уже были. Это, в свою очередь, поддерживало истинную цель @ ngrx / store - кэшировать любые данные, которые я получал.