mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 01:54:15 +00:00
398 lines
17 KiB
TypeScript
398 lines
17 KiB
TypeScript
/* eslint-disable max-classes-per-file */
|
|
import { Injectable } from '@angular/core';
|
|
import { Angulartics2 } from 'angulartics2';
|
|
import {
|
|
BehaviorSubject,
|
|
combineLatest as observableCombineLatest,
|
|
Observable,
|
|
} from 'rxjs';
|
|
import {
|
|
distinctUntilChanged,
|
|
map,
|
|
switchMap,
|
|
take,
|
|
tap,
|
|
} from 'rxjs/operators';
|
|
|
|
import {
|
|
hasValue,
|
|
hasValueOperator,
|
|
isNotEmpty,
|
|
} from '../../../shared/empty.util';
|
|
import { ListableObject } from '../../../shared/object-collection/shared/listable-object.model';
|
|
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
|
|
import { AppliedFilter } from '../../../shared/search/models/applied-filter.model';
|
|
import { FacetValues } from '../../../shared/search/models/facet-values.model';
|
|
import { PaginatedSearchOptions } from '../../../shared/search/models/paginated-search-options.model';
|
|
import { SearchFilterConfig } from '../../../shared/search/models/search-filter-config.model';
|
|
import { SearchObjects } from '../../../shared/search/models/search-objects.model';
|
|
import { SearchResult } from '../../../shared/search/models/search-result.model';
|
|
import { getSearchResultFor } from '../../../shared/search/search-result-element-decorator';
|
|
import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
|
|
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
|
|
import { BaseDataService } from '../../data/base/base-data.service';
|
|
import { DSpaceObjectDataService } from '../../data/dspace-object-data.service';
|
|
import { FacetValueResponseParsingService } from '../../data/facet-value-response-parsing.service';
|
|
import { ResponseParsingService } from '../../data/parsing.service';
|
|
import { RemoteData } from '../../data/remote-data';
|
|
import { GetRequest } from '../../data/request.models';
|
|
import { RequestService } from '../../data/request.service';
|
|
import { RestRequest } from '../../data/rest-request.model';
|
|
import { SearchResponseParsingService } from '../../data/search-response-parsing.service';
|
|
import { PaginationService } from '../../pagination/pagination.service';
|
|
import { RouteService } from '../../services/route.service';
|
|
import { URLCombiner } from '../../url-combiner/url-combiner';
|
|
import { DSpaceObject } from '../dspace-object.model';
|
|
import { GenericConstructor } from '../generic-constructor';
|
|
import { HALEndpointService } from '../hal-endpoint.service';
|
|
import {
|
|
getFirstCompletedRemoteData,
|
|
getRemoteDataPayload,
|
|
} from '../operators';
|
|
import { ViewMode } from '../view-mode.model';
|
|
import { SearchConfigurationService } from './search-configuration.service';
|
|
|
|
/**
|
|
* A limited data service implementation for the 'discover' endpoint
|
|
* - Overrides {@link BaseDataService.addEmbedParams} in order to make it public
|
|
*
|
|
* Doesn't use any of the service's dependencies, they are initialized as undefined
|
|
* Therefore, request/response handling methods won't work even though they're defined
|
|
*/
|
|
class SearchDataService extends BaseDataService<any> {
|
|
constructor() {
|
|
super('discover', undefined, undefined, undefined, undefined);
|
|
}
|
|
|
|
/**
|
|
* Adds the embed options to the link for the request
|
|
* @param href The href the params are to be added to
|
|
* @param args params for the query string
|
|
* @param linksToFollow links we want to embed in query string if shouldEmbed is true
|
|
*/
|
|
public addEmbedParams(href: string, args: string[], ...linksToFollow: FollowLinkConfig<any>[]) {
|
|
return super.addEmbedParams(href, args, ...linksToFollow);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Service that performs all general actions that have to do with the search page
|
|
*/
|
|
@Injectable({ providedIn: 'root' })
|
|
export class SearchService {
|
|
|
|
/**
|
|
* Endpoint link path for retrieving general search results
|
|
*/
|
|
private searchLinkPath = 'discover/search/objects';
|
|
|
|
/**
|
|
* The ResponseParsingService constructor name
|
|
*/
|
|
private parser: GenericConstructor<ResponseParsingService> = SearchResponseParsingService;
|
|
|
|
/**
|
|
* The RestRequest constructor name
|
|
*/
|
|
private request: GenericConstructor<RestRequest> = GetRequest;
|
|
|
|
/**
|
|
* Instance of SearchDataService to forward data service methods to
|
|
*/
|
|
private searchDataService: SearchDataService;
|
|
|
|
public appliedFilters$: BehaviorSubject<AppliedFilter[]> = new BehaviorSubject([]);
|
|
|
|
constructor(
|
|
private routeService: RouteService,
|
|
protected requestService: RequestService,
|
|
private rdb: RemoteDataBuildService,
|
|
private halService: HALEndpointService,
|
|
private dspaceObjectService: DSpaceObjectDataService,
|
|
private paginationService: PaginationService,
|
|
private searchConfigurationService: SearchConfigurationService,
|
|
private angulartics2: Angulartics2,
|
|
) {
|
|
this.searchDataService = new SearchDataService();
|
|
}
|
|
|
|
/**
|
|
* Get the currently {@link AppliedFilter}s for the given filter.
|
|
*
|
|
* @param filterName The name of the filter
|
|
*/
|
|
getSelectedValuesForFilter(filterName: string): Observable<AppliedFilter[]> {
|
|
return this.appliedFilters$.pipe(
|
|
map((appliedFilters: AppliedFilter[]) => appliedFilters.filter((appliedFilter: AppliedFilter) => appliedFilter.filter === filterName)),
|
|
distinctUntilChanged((previous: AppliedFilter[], next: AppliedFilter[]) => JSON.stringify(previous) === JSON.stringify(next)),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Method to set service options
|
|
* @param {GenericConstructor<ResponseParsingService>} parser The ResponseParsingService constructor name
|
|
* @param {boolean} request The RestRequest constructor name
|
|
*/
|
|
setServiceOptions(parser: GenericConstructor<ResponseParsingService>, request: GenericConstructor<RestRequest>) {
|
|
if (parser) {
|
|
this.parser = parser;
|
|
}
|
|
if (request) {
|
|
this.request = request;
|
|
}
|
|
}
|
|
|
|
getEndpoint(searchOptions?: PaginatedSearchOptions): Observable<string> {
|
|
return this.halService.getEndpoint(this.searchLinkPath).pipe(
|
|
map((url: string) => {
|
|
if (hasValue(searchOptions)) {
|
|
return (searchOptions as PaginatedSearchOptions).toRestUrl(url);
|
|
} else {
|
|
return url;
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Method to retrieve a paginated list of search results from the server
|
|
* @param {PaginatedSearchOptions} searchOptions The configuration necessary to perform this search
|
|
* @param responseMsToLive The amount of milliseconds for the response to live in cache
|
|
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
|
|
* no valid cached version. Defaults to true
|
|
* @param reRequestOnStale Whether or not the request should automatically be re-requested after
|
|
* the response becomes stale
|
|
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
|
|
* @returns {Observable<RemoteData<SearchObjects<T>>>} Emits a paginated list with all search results found
|
|
*/
|
|
search<T extends DSpaceObject>(searchOptions?: PaginatedSearchOptions, responseMsToLive?: number, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<SearchObjects<T>>> {
|
|
const href$ = this.getEndpoint(searchOptions);
|
|
|
|
href$.pipe(
|
|
take(1),
|
|
map((href: string) => {
|
|
const args = this.searchDataService.addEmbedParams(href, [], ...linksToFollow);
|
|
if (isNotEmpty(args)) {
|
|
return new URLCombiner(href, `?${args.join('&')}`).toString();
|
|
} else {
|
|
return href;
|
|
}
|
|
}),
|
|
).subscribe((url: string) => {
|
|
const request = new this.request(this.requestService.generateRequestId(), url);
|
|
|
|
const getResponseParserFn: () => GenericConstructor<ResponseParsingService> = () => {
|
|
return this.parser;
|
|
};
|
|
|
|
Object.assign(request, {
|
|
responseMsToLive: hasValue(responseMsToLive) ? responseMsToLive : request.responseMsToLive,
|
|
getResponseParser: getResponseParserFn,
|
|
searchOptions: searchOptions,
|
|
});
|
|
|
|
this.requestService.send(request, useCachedVersionIfAvailable);
|
|
});
|
|
|
|
const sqr$ = href$.pipe(
|
|
switchMap((href: string) => this.rdb.buildFromHref<SearchObjects<T>>(href)),
|
|
);
|
|
|
|
return this.directlyAttachIndexableObjects(sqr$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
|
|
}
|
|
|
|
/**
|
|
* Method to directly attach the indexableObjects to search results, instead of using RemoteData.
|
|
* For compatibility with the way the search was written originally
|
|
*
|
|
* @param sqr$ A {@link SearchObjects} {@link RemoteData} Observable without its
|
|
* indexableObjects attached
|
|
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
|
|
* no valid cached version. Defaults to true
|
|
* @param reRequestOnStale Whether or not the request should automatically be re-
|
|
* requested after the response becomes stale
|
|
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
|
|
* {@link HALLink}s should be automatically resolved
|
|
* @protected
|
|
*/
|
|
protected directlyAttachIndexableObjects<T extends DSpaceObject>(sqr$: Observable<RemoteData<SearchObjects<T>>>, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<SearchObjects<T>>> {
|
|
return sqr$.pipe(
|
|
switchMap((resultsRd: RemoteData<SearchObjects<T>>) => {
|
|
if (hasValue(resultsRd.payload) && isNotEmpty(resultsRd.payload.page)) {
|
|
// retrieve the indexableObjects for all search results on the page
|
|
const searchResult$Array: Observable<SearchResult<T>>[] = resultsRd.payload.page.map((result: SearchResult<T>) =>
|
|
this.dspaceObjectService.findByHref(result._links.indexableObject.href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow as any).pipe(
|
|
getFirstCompletedRemoteData(),
|
|
getRemoteDataPayload(),
|
|
hasValueOperator(),
|
|
map((indexableObject: DSpaceObject) => {
|
|
// determine the constructor of the search result (ItemSearchResult,
|
|
// CollectionSearchResult, etc) based on the kind of the indeaxbleObject it
|
|
// contains. Recreate the result with that constructor
|
|
const constructor: GenericConstructor<ListableObject> = indexableObject.constructor as GenericConstructor<ListableObject>;
|
|
const resultConstructor = getSearchResultFor(constructor);
|
|
|
|
// Attach the payload directly to the indexableObject property on the result
|
|
return Object.assign(new resultConstructor(), result, {
|
|
indexableObject,
|
|
}) as SearchResult<T>;
|
|
}),
|
|
),
|
|
);
|
|
|
|
// Swap the original page in the remoteData with the new one, now that the results have the
|
|
// correct types, and all indexableObjects are directly attached.
|
|
return observableCombineLatest(searchResult$Array).pipe(
|
|
map((page: SearchResult<T>[]) => {
|
|
|
|
const payload = Object.assign(new SearchObjects(), resultsRd.payload, {
|
|
page,
|
|
}) as SearchObjects<T>;
|
|
|
|
return new RemoteData(
|
|
resultsRd.timeCompleted,
|
|
resultsRd.msToLive,
|
|
resultsRd.lastUpdated,
|
|
resultsRd.state,
|
|
resultsRd.errorMessage,
|
|
payload,
|
|
resultsRd.statusCode,
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
// If we don't have a payload, or the page is empty, simply pass on the unmodified
|
|
// RemoteData object
|
|
return [resultsRd];
|
|
}),
|
|
);
|
|
}
|
|
|
|
|
|
/**
|
|
* Method to request a single page of filter values for a given value
|
|
* @param {SearchFilterConfig} filterConfig The filter config for which we want to request filter values
|
|
* @param {number} valuePage The page number of the filter values
|
|
* @param {SearchOptions} searchOptions The search configuration for the current search
|
|
* @param {string} filterQuery The optional query used to filter out filter values
|
|
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
|
|
* no valid cached version. Defaults to true
|
|
* @returns {Observable<RemoteData<PaginatedList<FacetValue>>>} Emits the given page of facet values
|
|
*/
|
|
getFacetValuesFor(filterConfig: SearchFilterConfig, valuePage: number, searchOptions?: PaginatedSearchOptions, filterQuery?: string, useCachedVersionIfAvailable = true): Observable<RemoteData<FacetValues>> {
|
|
let href;
|
|
let args: string[] = [];
|
|
if (hasValue(filterQuery)) {
|
|
args.push(`prefix=${encodeURIComponent(filterQuery)}`);
|
|
}
|
|
if (hasValue(searchOptions)) {
|
|
searchOptions = Object.assign(new PaginatedSearchOptions({}), searchOptions, {
|
|
pagination: Object.assign({}, searchOptions.pagination, {
|
|
currentPage: valuePage,
|
|
pageSize: filterConfig.pageSize,
|
|
}),
|
|
});
|
|
href = searchOptions.toRestUrl(filterConfig._links.self.href, args);
|
|
} else {
|
|
args = [`page=${valuePage - 1}`, `size=${filterConfig.pageSize}`, ...args];
|
|
href = new URLCombiner(filterConfig._links.self.href, `?${args.join('&')}`).toString();
|
|
}
|
|
|
|
let request = new this.request(this.requestService.generateRequestId(), href);
|
|
request = Object.assign(request, {
|
|
getResponseParser(): GenericConstructor<ResponseParsingService> {
|
|
return FacetValueResponseParsingService;
|
|
},
|
|
});
|
|
this.requestService.send(request, useCachedVersionIfAvailable);
|
|
|
|
return this.rdb.buildFromHref(href).pipe(
|
|
tap((facetValuesRD: RemoteData<FacetValues>) => {
|
|
if (facetValuesRD.hasSucceeded) {
|
|
const appliedFilters: AppliedFilter[] = (facetValuesRD.payload.appliedFilters ?? [])
|
|
.filter((appliedFilter: AppliedFilter) => hasValue(appliedFilter))
|
|
// TODO this should ideally be fixed in the backend
|
|
.map((appliedFilter: AppliedFilter) => Object.assign({}, appliedFilter, {
|
|
operator: hasValue(appliedFilter.value.match(/\[\s*(\*|\d+)\s*TO\s*(\*|\d+)\s*]/)) ? 'range' : appliedFilter.operator,
|
|
}));
|
|
this.appliedFilters$.next(appliedFilters);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Requests the current view mode based on the current URL
|
|
* @returns {Observable<ViewMode>} The current view mode
|
|
*/
|
|
getViewMode(): Observable<ViewMode> {
|
|
return this.routeService.getQueryParamMap().pipe(map((params) => {
|
|
if (isNotEmpty(params.get('view')) && hasValue(params.get('view'))) {
|
|
return params.get('view');
|
|
} else {
|
|
return ViewMode.ListElement;
|
|
}
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Changes the current view mode in the current URL
|
|
* @param {ViewMode} viewMode Mode to switch to
|
|
* @param {string[]} searchLinkParts
|
|
*/
|
|
setViewMode(viewMode: ViewMode, searchLinkParts?: string[]) {
|
|
this.paginationService.getCurrentPagination(this.searchConfigurationService.paginationID, new PaginationComponentOptions()).pipe(take(1))
|
|
.subscribe((config) => {
|
|
let pageParams = { page: 1 };
|
|
const queryParams = { view: viewMode };
|
|
if (viewMode === ViewMode.DetailedListElement) {
|
|
pageParams = Object.assign(pageParams, { pageSize: 1 });
|
|
} else if (config.pageSize === 1) {
|
|
pageParams = Object.assign(pageParams, { pageSize: 10 });
|
|
}
|
|
this.paginationService.updateRouteWithUrl(this.searchConfigurationService.paginationID, hasValue(searchLinkParts) ? searchLinkParts : [this.getSearchLink()], pageParams, queryParams);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Send search event to rest api using angularitics
|
|
* @param config Paginated search options used
|
|
* @param searchQueryResponse The response objects of the performed search
|
|
* @param clickedObject Optional UUID of an object a search was performed and clicked for
|
|
*/
|
|
trackSearch(config: PaginatedSearchOptions, searchQueryResponse: SearchObjects<DSpaceObject>, clickedObject?: string) {
|
|
const filters: { filter: string, operator: string, value: string, label: string; }[] = [];
|
|
const appliedFilters = searchQueryResponse.appliedFilters || [];
|
|
for (let i = 0, filtersLength = appliedFilters.length; i < filtersLength; i++) {
|
|
const appliedFilter = appliedFilters[i];
|
|
filters.push(appliedFilter);
|
|
}
|
|
this.angulartics2.eventTrack.next({
|
|
action: 'search',
|
|
properties: {
|
|
searchOptions: config,
|
|
page: {
|
|
size: config.pagination.size, // same as searchQueryResponse.page.elementsPerPage
|
|
totalElements: searchQueryResponse.pageInfo.totalElements,
|
|
totalPages: searchQueryResponse.pageInfo.totalPages,
|
|
number: config.pagination.currentPage, // same as searchQueryResponse.page.currentPage
|
|
},
|
|
sort: {
|
|
by: config.sort.field,
|
|
order: config.sort.direction,
|
|
},
|
|
filters: filters,
|
|
clickedObject,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @returns {string} The base path to the search page
|
|
*/
|
|
getSearchLink(): string {
|
|
return '/search';
|
|
}
|
|
|
|
}
|