mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 01:54:15 +00:00
518 lines
17 KiB
TypeScript
518 lines
17 KiB
TypeScript
import { ChangeDetectionStrategy, Component, EventEmitter, Inject, Input, OnInit, Output } from '@angular/core';
|
|
import { NavigationStart, Router } from '@angular/router';
|
|
|
|
import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
|
|
import { debounceTime, distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators';
|
|
import uniqueId from 'lodash/uniqueId';
|
|
|
|
import { PaginatedList } from '../../core/data/paginated-list.model';
|
|
import { RemoteData } from '../../core/data/remote-data';
|
|
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
|
import { pushInOut } from '../animations/push';
|
|
import { HostWindowService } from '../host-window.service';
|
|
import { SidebarService } from '../sidebar/sidebar.service';
|
|
import { hasValue, hasValueOperator, isNotEmpty } from '../empty.util';
|
|
import { RouteService } from '../../core/services/route.service';
|
|
import { SEARCH_CONFIG_SERVICE } from '../../my-dspace-page/my-dspace-page.component';
|
|
import { PaginatedSearchOptions } from './models/paginated-search-options.model';
|
|
import { SearchResult } from './models/search-result.model';
|
|
import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service';
|
|
import { SearchService } from '../../core/shared/search/search.service';
|
|
import { currentPath } from '../utils/route.utils';
|
|
import { Context } from '../../core/shared/context.model';
|
|
import { SortOptions } from '../../core/cache/models/sort-options.model';
|
|
import { SearchConfig } from '../../core/shared/search/search-filters/search-config.model';
|
|
import { SearchConfigurationOption } from './search-switch-configuration/search-configuration-option.model';
|
|
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
|
|
import { followLink } from '../utils/follow-link-config.model';
|
|
import { Item } from '../../core/shared/item.model';
|
|
import { SearchObjects } from './models/search-objects.model';
|
|
import { ViewMode } from '../../core/shared/view-mode.model';
|
|
import { SelectionConfig } from './search-results/search-results.component';
|
|
import { ListableObject } from '../object-collection/shared/listable-object.model';
|
|
import { CollectionElementLinkType } from '../object-collection/collection-element-link.type';
|
|
import { environment } from 'src/environments/environment';
|
|
import { SubmissionObject } from '../../core/submission/models/submission-object.model';
|
|
import { SearchFilterConfig } from './models/search-filter-config.model';
|
|
import { WorkspaceItem } from '../../core/submission/models/workspaceitem.model';
|
|
import { ITEM_MODULE_PATH } from '../../item-page/item-page-routing-paths';
|
|
import { COLLECTION_MODULE_PATH } from '../../collection-page/collection-page-routing-paths';
|
|
import { COMMUNITY_MODULE_PATH } from '../../community-page/community-page-routing-paths';
|
|
|
|
@Component({
|
|
selector: 'ds-search',
|
|
styleUrls: ['./search.component.scss'],
|
|
templateUrl: './search.component.html',
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
animations: [pushInOut],
|
|
})
|
|
|
|
/**
|
|
* This component renders a sidebar, a search input bar and the search results.
|
|
*/
|
|
export class SearchComponent implements OnInit {
|
|
|
|
/**
|
|
* The list of available configuration options
|
|
*/
|
|
@Input() configurationList: SearchConfigurationOption[] = [];
|
|
|
|
/**
|
|
* The current context
|
|
* If empty, 'search' is used
|
|
*/
|
|
@Input() context: Context = Context.Search;
|
|
|
|
/**
|
|
* The configuration to use for the search options
|
|
* If empty, 'default' is used
|
|
*/
|
|
@Input() configuration;
|
|
|
|
/**
|
|
* The actual query for the fixed filter.
|
|
* If empty, the query will be determined by the route parameter called 'filter'
|
|
*/
|
|
@Input() fixedFilterQuery: string;
|
|
|
|
/**
|
|
* If this is true, the request will only be sent if there's
|
|
* no valid cached version. Defaults to true
|
|
*/
|
|
@Input() useCachedVersionIfAvailable = true;
|
|
|
|
/**
|
|
* True when the search component should show results on the current page
|
|
*/
|
|
@Input() inPlaceSearch = true;
|
|
|
|
/**
|
|
* The link type of the listed search results
|
|
*/
|
|
@Input() linkType: CollectionElementLinkType;
|
|
|
|
/**
|
|
* The pagination id used in the search
|
|
*/
|
|
@Input() paginationId = 'spc';
|
|
|
|
/**
|
|
* Whether or not the search bar should be visible
|
|
*/
|
|
@Input() searchEnabled = true;
|
|
|
|
/**
|
|
* The width of the sidebar (bootstrap columns)
|
|
*/
|
|
@Input() sideBarWidth = 3;
|
|
|
|
/**
|
|
* The placeholder of the search form input
|
|
*/
|
|
@Input() searchFormPlaceholder = 'search.search-form.placeholder';
|
|
|
|
/**
|
|
* A boolean representing if result entries are selectable
|
|
*/
|
|
@Input() selectable = false;
|
|
|
|
/**
|
|
* The config option used for selection functionality
|
|
*/
|
|
@Input() selectionConfig: SelectionConfig;
|
|
|
|
/**
|
|
* A boolean representing if show csv export button
|
|
*/
|
|
@Input() showCsvExport = false;
|
|
|
|
/**
|
|
* A boolean representing if show search sidebar button
|
|
*/
|
|
@Input() showSidebar = true;
|
|
|
|
/**
|
|
* Whether to show the thumbnail preview
|
|
*/
|
|
@Input() showThumbnails;
|
|
|
|
/**
|
|
* Whether to show the view mode switch
|
|
*/
|
|
@Input() showViewModes = true;
|
|
|
|
/**
|
|
* List of available view mode
|
|
*/
|
|
@Input() useUniquePageId: boolean;
|
|
|
|
/**
|
|
* List of available view mode
|
|
*/
|
|
@Input() viewModeList: ViewMode[];
|
|
|
|
/**
|
|
* Defines whether or not to show the scope selector
|
|
*/
|
|
@Input() showScopeSelector = true;
|
|
|
|
/**
|
|
* Whether or not to track search statistics by sending updates to the rest api
|
|
*/
|
|
@Input() trackStatistics = false;
|
|
|
|
/**
|
|
* The default value for the search query when none is already defined in the {@link SearchConfigurationService}
|
|
*/
|
|
@Input() query: string;
|
|
|
|
/**
|
|
* The current configuration used during the search
|
|
*/
|
|
currentConfiguration$: BehaviorSubject<string> = new BehaviorSubject<string>('');
|
|
|
|
/**
|
|
* The current context used during the search
|
|
*/
|
|
currentContext$: BehaviorSubject<Context> = new BehaviorSubject<Context>(null);
|
|
|
|
/**
|
|
* The current sort options used
|
|
*/
|
|
currentScope$: BehaviorSubject<string> = new BehaviorSubject<string>('');
|
|
|
|
/**
|
|
* The current sort options used
|
|
*/
|
|
currentSortOptions$: BehaviorSubject<SortOptions> = new BehaviorSubject<SortOptions>(null);
|
|
|
|
/**
|
|
* An observable containing configuration about which filters are shown and how they are shown
|
|
*/
|
|
filtersRD$: BehaviorSubject<RemoteData<SearchFilterConfig[]>> = new BehaviorSubject<RemoteData<SearchFilterConfig[]>>(null);
|
|
|
|
/**
|
|
* Maintains the last search options, so it can be used in refresh
|
|
*/
|
|
lastSearchOptions: PaginatedSearchOptions;
|
|
|
|
/**
|
|
* The current search results
|
|
*/
|
|
resultsRD$: BehaviorSubject<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>> = new BehaviorSubject(null);
|
|
|
|
/**
|
|
* The current paginated search options
|
|
*/
|
|
searchOptions$: BehaviorSubject<PaginatedSearchOptions> = new BehaviorSubject<PaginatedSearchOptions>(null);
|
|
|
|
/**
|
|
* The available sort options list
|
|
*/
|
|
sortOptionsList$: BehaviorSubject<SortOptions[]> = new BehaviorSubject<SortOptions[]>([]);
|
|
|
|
/**
|
|
* TRUE if the search option are initialized
|
|
*/
|
|
initialized$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
|
|
|
/**
|
|
* Observable for whether or not the sidebar is currently collapsed
|
|
*/
|
|
isSidebarCollapsed$: Observable<boolean>;
|
|
|
|
/**
|
|
* Emits true if were on a small screen
|
|
*/
|
|
isXsOrSm$: Observable<boolean>;
|
|
|
|
/**
|
|
* Emits when the search filters values may be stale, and so they must be refreshed.
|
|
*/
|
|
refreshFilters: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
|
|
|
/**
|
|
* Link to the search page
|
|
*/
|
|
searchLink: string;
|
|
|
|
/**
|
|
* Regex to match UUIDs
|
|
*/
|
|
uuidRegex = /\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b/g;
|
|
|
|
/**
|
|
* List of paths that are considered to be the start of a route to an object page (excluding "/", e.g. "items")
|
|
* These are expected to end on an object UUID
|
|
* If they match the route we're navigating to, an object property will be added to the search event sent
|
|
*/
|
|
allowedObjectPaths: string[] = ['entities', ITEM_MODULE_PATH, COLLECTION_MODULE_PATH, COMMUNITY_MODULE_PATH];
|
|
|
|
/**
|
|
* Subscriptions to unsubscribe from
|
|
*/
|
|
subs: Subscription[] = [];
|
|
|
|
/**
|
|
* Emits an event with the current search result entries
|
|
*/
|
|
@Output() resultFound: EventEmitter<SearchObjects<DSpaceObject>> = new EventEmitter<SearchObjects<DSpaceObject>>();
|
|
|
|
/**
|
|
* Emits event when the user deselect result entry
|
|
*/
|
|
@Output() deselectObject: EventEmitter<ListableObject> = new EventEmitter<ListableObject>();
|
|
|
|
/**
|
|
* Emits event when the user select result entry
|
|
*/
|
|
@Output() selectObject: EventEmitter<ListableObject> = new EventEmitter<ListableObject>();
|
|
|
|
constructor(protected service: SearchService,
|
|
protected sidebarService: SidebarService,
|
|
protected windowService: HostWindowService,
|
|
@Inject(SEARCH_CONFIG_SERVICE) public searchConfigService: SearchConfigurationService,
|
|
protected routeService: RouteService,
|
|
protected router: Router) {
|
|
this.isXsOrSm$ = this.windowService.isXsOrSm();
|
|
}
|
|
|
|
/**
|
|
* Listening to changes in the paginated search options
|
|
* If something changes, update the search results
|
|
*
|
|
* Listen to changes in the scope
|
|
* If something changes, update the list of scopes for the dropdown
|
|
*/
|
|
ngOnInit(): void {
|
|
if (this.useUniquePageId) {
|
|
// Create an unique pagination id related to the instance of the SearchComponent
|
|
this.paginationId = uniqueId(this.paginationId);
|
|
}
|
|
|
|
this.searchConfigService.setPaginationId(this.paginationId);
|
|
|
|
if (hasValue(this.configuration)) {
|
|
this.routeService.setParameter('configuration', this.configuration);
|
|
}
|
|
if (hasValue(this.fixedFilterQuery)) {
|
|
this.routeService.setParameter('fixedFilterQuery', this.fixedFilterQuery);
|
|
}
|
|
|
|
this.isSidebarCollapsed$ = this.isSidebarCollapsed();
|
|
this.searchLink = this.getSearchLink();
|
|
this.currentContext$.next(this.context);
|
|
|
|
// Determinate PaginatedSearchOptions and listen to any update on it
|
|
const configuration$: Observable<string> = this.searchConfigService
|
|
.getCurrentConfiguration(this.configuration).pipe(distinctUntilChanged());
|
|
const searchSortOptions$: Observable<SortOptions[]> = configuration$.pipe(
|
|
switchMap((configuration: string) => this.searchConfigService
|
|
.getConfigurationSearchConfig(configuration)),
|
|
map((searchConfig: SearchConfig) => this.searchConfigService.getConfigurationSortOptions(searchConfig)),
|
|
distinctUntilChanged()
|
|
);
|
|
const sortOption$: Observable<SortOptions> = searchSortOptions$.pipe(
|
|
switchMap((searchSortOptions: SortOptions[]) => {
|
|
const defaultSort: SortOptions = searchSortOptions[0];
|
|
return this.searchConfigService.getCurrentSort(this.paginationId, defaultSort);
|
|
}),
|
|
distinctUntilChanged()
|
|
);
|
|
const searchOptions$: Observable<PaginatedSearchOptions> = this.getSearchOptions().pipe(distinctUntilChanged());
|
|
|
|
this.subs.push(combineLatest([configuration$, searchSortOptions$, searchOptions$, sortOption$]).pipe(
|
|
filter(([configuration, searchSortOptions, searchOptions, sortOption]: [string, SortOptions[], PaginatedSearchOptions, SortOptions]) => {
|
|
// filter for search options related to instanced paginated id
|
|
return searchOptions.pagination.id === this.paginationId;
|
|
}),
|
|
debounceTime(100)
|
|
).subscribe(([configuration, searchSortOptions, searchOptions, sortOption]: [string, SortOptions[], PaginatedSearchOptions, SortOptions]) => {
|
|
// Build the PaginatedSearchOptions object
|
|
const combinedOptions = Object.assign({}, searchOptions,
|
|
{
|
|
configuration: searchOptions.configuration || configuration,
|
|
sort: sortOption || searchOptions.sort
|
|
});
|
|
if (combinedOptions.query === '') {
|
|
combinedOptions.query = this.query;
|
|
}
|
|
const newSearchOptions = new PaginatedSearchOptions(combinedOptions);
|
|
// check if search options are changed
|
|
// if so retrieve new related results otherwise skip it
|
|
if (JSON.stringify(newSearchOptions) !== JSON.stringify(this.searchOptions$.value)) {
|
|
// Initialize variables
|
|
this.currentConfiguration$.next(configuration);
|
|
this.currentSortOptions$.next(newSearchOptions.sort);
|
|
this.currentScope$.next(newSearchOptions.scope);
|
|
this.sortOptionsList$.next(searchSortOptions);
|
|
this.searchOptions$.next(newSearchOptions);
|
|
this.initialized$.next(true);
|
|
// retrieve results
|
|
this.retrieveSearchResults(newSearchOptions);
|
|
this.retrieveFilters(searchOptions);
|
|
}
|
|
}));
|
|
|
|
this.subscribeToRoutingEvents();
|
|
}
|
|
|
|
/**
|
|
* Change the current context
|
|
* @param context
|
|
*/
|
|
public changeContext(context: Context) {
|
|
this.currentContext$.next(context);
|
|
}
|
|
|
|
/**
|
|
* Set the sidebar to a collapsed state
|
|
*/
|
|
public closeSidebar(): void {
|
|
this.sidebarService.collapse();
|
|
}
|
|
|
|
/**
|
|
* Reset result list on view mode change
|
|
*/
|
|
public changeViewMode() {
|
|
this.resultsRD$.next(null);
|
|
}
|
|
|
|
/**
|
|
* Set the sidebar to an expanded state
|
|
*/
|
|
public openSidebar(): void {
|
|
this.sidebarService.expand();
|
|
}
|
|
|
|
/**
|
|
* Emit event to refresh filter content
|
|
* @param $event
|
|
*/
|
|
public onContentChange($event: any) {
|
|
this.retrieveFilters(this.lastSearchOptions);
|
|
this.refreshFilters.next(true);
|
|
}
|
|
|
|
/**
|
|
* Unsubscribe from the subscriptions
|
|
*/
|
|
ngOnDestroy(): void {
|
|
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
|
|
}
|
|
|
|
/**
|
|
* Get the current paginated search options
|
|
* @returns {Observable<PaginatedSearchOptions>}
|
|
*/
|
|
protected getSearchOptions(): Observable<PaginatedSearchOptions> {
|
|
return this.searchConfigService.paginatedSearchOptions;
|
|
}
|
|
|
|
/**
|
|
* Retrieve search filters by the given search options
|
|
* @param searchOptions
|
|
* @private
|
|
*/
|
|
private retrieveFilters(searchOptions: PaginatedSearchOptions) {
|
|
this.searchConfigService.getConfig(searchOptions.scope, searchOptions.configuration).pipe(
|
|
getFirstCompletedRemoteData(),
|
|
).subscribe((filtersRD: RemoteData<SearchFilterConfig[]>) => {
|
|
this.filtersRD$.next(filtersRD);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Retrieve search result by the given search options
|
|
* @param searchOptions
|
|
* @private
|
|
*/
|
|
private retrieveSearchResults(searchOptions: PaginatedSearchOptions) {
|
|
this.resultsRD$.next(null);
|
|
this.lastSearchOptions = searchOptions;
|
|
let followLinks = [
|
|
followLink<Item>('thumbnail', { isOptional: true }),
|
|
followLink<SubmissionObject>('item', { isOptional: true }, followLink<Item>('thumbnail', { isOptional: true })) as any,
|
|
followLink<Item>('accessStatus', { isOptional: true, shouldEmbed: environment.item.showAccessStatuses }),
|
|
];
|
|
if (this.configuration === 'supervision') {
|
|
followLinks.push(followLink<WorkspaceItem>('supervisionOrders', { isOptional: true }) as any);
|
|
}
|
|
this.service.search(
|
|
searchOptions,
|
|
undefined,
|
|
this.useCachedVersionIfAvailable,
|
|
true,
|
|
...followLinks
|
|
).pipe(getFirstCompletedRemoteData())
|
|
.subscribe((results: RemoteData<SearchObjects<DSpaceObject>>) => {
|
|
if (results.hasSucceeded) {
|
|
if (this.trackStatistics) {
|
|
this.service.trackSearch(searchOptions, results.payload);
|
|
}
|
|
if (results.payload?.page?.length > 0) {
|
|
this.resultFound.emit(results.payload);
|
|
}
|
|
}
|
|
this.resultsRD$.next(results);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Subscribe to routing events to detect when a user moves away from the search page
|
|
* When the user is routing to an object page, it needs to send out a separate search event containing that object's UUID
|
|
* This method should only be called once and is essentially what SearchTrackingComponent used to do (now removed)
|
|
* @private
|
|
*/
|
|
private subscribeToRoutingEvents() {
|
|
this.subs.push(
|
|
this.router.events.pipe(
|
|
filter((event) => event instanceof NavigationStart),
|
|
map((event: NavigationStart) => this.getDsoUUIDFromUrl(event.url)),
|
|
hasValueOperator(),
|
|
).subscribe((uuid) => {
|
|
if (this.resultsRD$.value.hasSucceeded) {
|
|
this.service.trackSearch(this.searchOptions$.value, this.resultsRD$.value.payload as SearchObjects<DSpaceObject>, uuid);
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get the UUID from a DSO url
|
|
* Return null if the url isn't an object page (allowedObjectPaths) or the UUID couldn't be found
|
|
* @param url
|
|
*/
|
|
private getDsoUUIDFromUrl(url: string): string {
|
|
if (isNotEmpty(url)) {
|
|
if (this.allowedObjectPaths.some((path) => url.startsWith(`/${path}`))) {
|
|
const uuid = url.substring(url.lastIndexOf('/') + 1);
|
|
if (uuid.match(this.uuidRegex)) {
|
|
return uuid;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Check if the sidebar is collapsed
|
|
* @returns {Observable<boolean>} emits true if the sidebar is currently collapsed, false if it is expanded
|
|
*/
|
|
private isSidebarCollapsed(): Observable<boolean> {
|
|
return this.sidebarService.isCollapsed;
|
|
}
|
|
|
|
/**
|
|
* @returns {string} The base path to the search page, or the current page when inPlaceSearch is true
|
|
*/
|
|
private getSearchLink(): string {
|
|
if (this.inPlaceSearch) {
|
|
return currentPath(this.router);
|
|
}
|
|
return this.service.getSearchLink();
|
|
}
|
|
|
|
}
|