mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 10:04:11 +00:00
50463: use search/facet endpoint to retrieve facet values
This commit is contained in:
@@ -117,7 +117,7 @@
|
|||||||
"placeholder": "Subject",
|
"placeholder": "Subject",
|
||||||
"head": "Subject"
|
"head": "Subject"
|
||||||
},
|
},
|
||||||
"date": {
|
"dateIssued": {
|
||||||
"placeholder": "Date",
|
"placeholder": "Date",
|
||||||
"head": "Date"
|
"head": "Date"
|
||||||
}
|
}
|
||||||
|
@@ -6,6 +6,7 @@ import { FacetValue } from '../../search-service/facet-value.model';
|
|||||||
import { SearchFilterService } from './search-filter.service';
|
import { SearchFilterService } from './search-filter.service';
|
||||||
import { Observable } from 'rxjs/Observable';
|
import { Observable } from 'rxjs/Observable';
|
||||||
import { slide } from '../../../shared/animations/slide';
|
import { slide } from '../../../shared/animations/slide';
|
||||||
|
import { PaginatedList } from '../../../core/data/paginated-list';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component renders a simple item page.
|
* This component renders a simple item page.
|
||||||
@@ -22,13 +23,13 @@ import { slide } from '../../../shared/animations/slide';
|
|||||||
|
|
||||||
export class SearchFilterComponent implements OnInit {
|
export class SearchFilterComponent implements OnInit {
|
||||||
@Input() filter: SearchFilterConfig;
|
@Input() filter: SearchFilterConfig;
|
||||||
filterValues: Observable<RemoteData<FacetValue[]>>;
|
filterValues: Observable<RemoteData<FacetValue[] | PaginatedList<FacetValue>>>;
|
||||||
|
|
||||||
constructor(private searchService: SearchService, private filterService: SearchFilterService) {
|
constructor(private searchService: SearchService, private filterService: SearchFilterService) {
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.filterValues = this.searchService.getFacetValuesFor(this.filter.name);
|
this.filterValues = this.searchService.getFacetValuesFor(this.filter.name, '', '');
|
||||||
const sub = this.filterService.isFilterActive(this.filter.paramName).first().subscribe((isActive) => {
|
const sub = this.filterService.isFilterActive(this.filter.paramName).first().subscribe((isActive) => {
|
||||||
if (this.filter.isOpenByDefault || isActive) {
|
if (this.filter.isOpenByDefault || isActive) {
|
||||||
this.initialExpand();
|
this.initialExpand();
|
||||||
|
@@ -1,7 +1,13 @@
|
|||||||
|
|
||||||
|
import { autoserialize, autoserializeAs } from 'cerialize';
|
||||||
|
|
||||||
export class FacetValue {
|
export class FacetValue {
|
||||||
|
@autoserializeAs(String, 'label')
|
||||||
value: string;
|
value: string;
|
||||||
|
|
||||||
|
@autoserialize
|
||||||
count: number;
|
count: number;
|
||||||
|
|
||||||
|
@autoserialize
|
||||||
search: string;
|
search: string;
|
||||||
}
|
}
|
||||||
|
@@ -1,13 +1,14 @@
|
|||||||
import { Inject, Injectable, OnDestroy } from '@angular/core';
|
import { Injectable, OnDestroy } from '@angular/core';
|
||||||
import { ActivatedRoute, NavigationExtras, Router } from '@angular/router';
|
import { ActivatedRoute, NavigationExtras, Router } from '@angular/router';
|
||||||
import { Observable } from 'rxjs/Observable';
|
import { Observable } from 'rxjs/Observable';
|
||||||
import { flatMap, map, tap } from 'rxjs/operators';
|
import { flatMap, map, tap } from 'rxjs/operators';
|
||||||
import { ViewMode } from '../../+search-page/search-options.model';
|
import { ViewMode } from '../../+search-page/search-options.model';
|
||||||
import { GLOBAL_CONFIG } from '../../../config';
|
|
||||||
import { GlobalConfig } from '../../../config/global-config.interface';
|
|
||||||
import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service';
|
import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service';
|
||||||
import { SortOptions } from '../../core/cache/models/sort-options.model';
|
import { SortOptions } from '../../core/cache/models/sort-options.model';
|
||||||
import { SearchSuccessResponse } from '../../core/cache/response-cache.models';
|
import {
|
||||||
|
FacetValueMapSuccessResponse, FacetValueSuccessResponse,
|
||||||
|
SearchSuccessResponse
|
||||||
|
} from '../../core/cache/response-cache.models';
|
||||||
import { ResponseCacheEntry } from '../../core/cache/response-cache.reducer';
|
import { ResponseCacheEntry } from '../../core/cache/response-cache.reducer';
|
||||||
import { ResponseCacheService } from '../../core/cache/response-cache.service';
|
import { ResponseCacheService } from '../../core/cache/response-cache.service';
|
||||||
import { PaginatedList } from '../../core/data/paginated-list';
|
import { PaginatedList } from '../../core/data/paginated-list';
|
||||||
@@ -33,6 +34,7 @@ import { SearchQueryResponse } from './search-query-response.model';
|
|||||||
import { PageInfo } from '../../core/shared/page-info.model';
|
import { PageInfo } from '../../core/shared/page-info.model';
|
||||||
import { getSearchResultFor } from './search-result-element-decorator';
|
import { getSearchResultFor } from './search-result-element-decorator';
|
||||||
import { ListableObject } from '../../shared/object-collection/shared/listable-object.model';
|
import { ListableObject } from '../../shared/object-collection/shared/listable-object.model';
|
||||||
|
import { FacetResponseParsingService } from '../../core/data/facet-response-parsing.service';
|
||||||
|
|
||||||
function shuffle(array: any[]) {
|
function shuffle(array: any[]) {
|
||||||
let i = 0;
|
let i = 0;
|
||||||
@@ -49,20 +51,21 @@ function shuffle(array: any[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SearchService extends HALEndpointService implements OnDestroy {
|
export class SearchService implements OnDestroy {
|
||||||
protected linkPath = 'discover/search/objects';
|
private searchLinkPath = 'discover/search/objects';
|
||||||
|
private facetLinkPath = 'discover/search/facets';
|
||||||
|
|
||||||
private sub;
|
private sub;
|
||||||
uiSearchRoute = '/search';
|
uiSearchRoute = '/search';
|
||||||
|
|
||||||
config: SearchFilterConfig[] = [
|
config: SearchFilterConfig[] = [
|
||||||
Object.assign(new SearchFilterConfig(),
|
// Object.assign(new SearchFilterConfig(),
|
||||||
{
|
// {
|
||||||
name: 'scope',
|
// name: 'scope',
|
||||||
type: FilterType.hierarchy,
|
// type: FilterType.hierarchy,
|
||||||
hasFacets: true,
|
// hasFacets: true,
|
||||||
isOpenByDefault: true
|
// isOpenByDefault: true
|
||||||
}),
|
// }),
|
||||||
Object.assign(new SearchFilterConfig(),
|
Object.assign(new SearchFilterConfig(),
|
||||||
{
|
{
|
||||||
name: 'author',
|
name: 'author',
|
||||||
@@ -72,7 +75,7 @@ export class SearchService extends HALEndpointService implements OnDestroy {
|
|||||||
}),
|
}),
|
||||||
Object.assign(new SearchFilterConfig(),
|
Object.assign(new SearchFilterConfig(),
|
||||||
{
|
{
|
||||||
name: 'date',
|
name: 'dateIssued',
|
||||||
type: FilterType.range,
|
type: FilterType.range,
|
||||||
hasFacets: true,
|
hasFacets: true,
|
||||||
isOpenByDefault: false
|
isOpenByDefault: false
|
||||||
@@ -92,10 +95,9 @@ export class SearchService extends HALEndpointService implements OnDestroy {
|
|||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
protected responseCache: ResponseCacheService,
|
protected responseCache: ResponseCacheService,
|
||||||
protected requestService: RequestService,
|
protected requestService: RequestService,
|
||||||
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
|
|
||||||
private routeService: RouteService,
|
private routeService: RouteService,
|
||||||
private rdb: RemoteDataBuildService,) {
|
private rdb: RemoteDataBuildService,
|
||||||
super();
|
private halService: HALEndpointService) {
|
||||||
const pagination: PaginationComponentOptions = new PaginationComponentOptions();
|
const pagination: PaginationComponentOptions = new PaginationComponentOptions();
|
||||||
pagination.id = 'search-results-pagination';
|
pagination.id = 'search-results-pagination';
|
||||||
pagination.currentPage = 1;
|
pagination.currentPage = 1;
|
||||||
@@ -106,7 +108,7 @@ export class SearchService extends HALEndpointService implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
search(query: string, scopeId?: string, searchOptions?: SearchOptions): Observable<RemoteData<Array<SearchResult<DSpaceObject>> | PaginatedList<SearchResult<DSpaceObject>>>> {
|
search(query: string, scopeId?: string, searchOptions?: SearchOptions): Observable<RemoteData<Array<SearchResult<DSpaceObject>> | PaginatedList<SearchResult<DSpaceObject>>>> {
|
||||||
const requestObs = this.getEndpoint().pipe(
|
const requestObs = this.halService.getEndpoint(this.searchLinkPath).pipe(
|
||||||
map((url: string) => {
|
map((url: string) => {
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
|
|
||||||
@@ -219,35 +221,92 @@ export class SearchService extends HALEndpointService implements OnDestroy {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
getFacetValuesFor(searchFilterConfigName: string): Observable<RemoteData<FacetValue[]>> {
|
getFacetValuesFor(searchFilterConfigName: string, query: string, scopeId: string): Observable<RemoteData<FacetValue[] | PaginatedList<FacetValue>>> {
|
||||||
const filterConfig = this.config.find((config: SearchFilterConfig) => config.name === searchFilterConfigName);
|
const requestObs = this.halService.getEndpoint(this.facetLinkPath).pipe(
|
||||||
return this.routeService.getQueryParameterValues(filterConfig.paramName).map((selectedValues: string[]) => {
|
map((url: string) => {
|
||||||
const payload: FacetValue[] = [];
|
const args: string[] = [];
|
||||||
const totalFilters = 13;
|
|
||||||
for (let i = 0; i < totalFilters; i++) {
|
if (isNotEmpty(query)) {
|
||||||
const value = searchFilterConfigName + ' ' + (i + 1);
|
args.push(`query=${query}`);
|
||||||
if (!selectedValues.includes(value)) {
|
|
||||||
payload.push({
|
|
||||||
value: value,
|
|
||||||
count: Math.floor(Math.random() * 20) + 20 * (totalFilters - i), // make sure first results have the highest (random) count
|
|
||||||
search: (decodeURI(this.router.url) + (this.router.url.includes('?') ? '&' : '?') + filterConfig.paramName + '=' + value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isNotEmpty(scopeId)) {
|
||||||
|
args.push(`scope=${scopeId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNotEmpty(args)) {
|
||||||
|
url = new URLCombiner(url, `?${args.join('&')}`).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = new GetRequest(this.requestService.generateRequestId(), url);
|
||||||
|
return Object.assign(request, {
|
||||||
|
getResponseParser(): GenericConstructor<ResponseParsingService> {
|
||||||
|
return FacetResponseParsingService;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
tap((request: RestRequest) => this.requestService.configure(request)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const requestEntryObs = requestObs.pipe(
|
||||||
|
flatMap((request: RestRequest) => this.requestService.getByHref(request.href))
|
||||||
|
);
|
||||||
|
|
||||||
|
const responseCacheObs = requestObs.pipe(
|
||||||
|
flatMap((request: RestRequest) => this.responseCache.get(request.href))
|
||||||
|
);
|
||||||
|
|
||||||
|
// get search results from response cache
|
||||||
|
const facetValueResponseObs: Observable<FacetValueSuccessResponse> = responseCacheObs.pipe(
|
||||||
|
map((entry: ResponseCacheEntry) => entry.response),
|
||||||
|
map((response: FacetValueMapSuccessResponse) => response.results[searchFilterConfigName])
|
||||||
|
);
|
||||||
|
|
||||||
|
// get search results from response cache
|
||||||
|
const facetValueObs: Observable<FacetValue[]> = facetValueResponseObs.pipe(
|
||||||
|
map((response: FacetValueSuccessResponse) => response.results)
|
||||||
|
);
|
||||||
|
|
||||||
|
const pageInfoObs: Observable<PageInfo> = facetValueResponseObs.pipe(
|
||||||
|
map((response: FacetValueSuccessResponse) => { console.log(response); return response.pageInfo})
|
||||||
|
);
|
||||||
|
const payloadObs = Observable.combineLatest(facetValueObs, pageInfoObs, (facetValue, pageInfo) => {
|
||||||
|
if (hasValue(pageInfo)) {
|
||||||
|
return new PaginatedList(pageInfo, facetValue);
|
||||||
|
} else {
|
||||||
|
return facetValue;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
const requestPending = false;
|
return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs);
|
||||||
const responsePending = false;
|
|
||||||
const isSuccessful = true;
|
// const filterConfig = this.config.find((config: SearchFilterConfig) => config.name === searchFilterConfigName);
|
||||||
const error = undefined;
|
// return this.routeService.getQueryParameterValues(filterConfig.paramName).map((selectedValues: string[]) => {
|
||||||
return new RemoteData(
|
// const payload: FacetValue[] = [];
|
||||||
requestPending,
|
// const totalFilters = 13;
|
||||||
responsePending,
|
// for (let i = 0; i < totalFilters; i++) {
|
||||||
isSuccessful,
|
// const value = searchFilterConfigName + ' ' + (i + 1);
|
||||||
error,
|
// if (!selectedValues.includes(value)) {
|
||||||
payload
|
// payload.push({
|
||||||
)
|
// value: value,
|
||||||
}
|
// count: Math.floor(Math.random() * 20) + 20 * (totalFilters - i), // make sure first results have the highest (random) count
|
||||||
)
|
// search: (decodeURI(this.router.url) + (this.router.url.includes('?') ? '&' : '?') + filterConfig.paramName + '=' + value)
|
||||||
|
// }
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// const requestPending = false;
|
||||||
|
// const responsePending = false;
|
||||||
|
// const isSuccessful = true;
|
||||||
|
// const error = undefined;
|
||||||
|
// return new RemoteData(
|
||||||
|
// requestPending,
|
||||||
|
// responsePending,
|
||||||
|
// isSuccessful,
|
||||||
|
// error,
|
||||||
|
// payload
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// )
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewMode(): Observable<ViewMode> {
|
getViewMode(): Observable<ViewMode> {
|
||||||
|
@@ -12,7 +12,7 @@ import { BrowseDefinition } from '../shared/browse-definition.model';
|
|||||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BrowseService extends HALEndpointService {
|
export class BrowseService {
|
||||||
protected linkPath = 'browses';
|
protected linkPath = 'browses';
|
||||||
|
|
||||||
private static toSearchKeyArray(metadatumKey: string): string[] {
|
private static toSearchKeyArray(metadatumKey: string): string[] {
|
||||||
@@ -31,13 +31,12 @@ export class BrowseService extends HALEndpointService {
|
|||||||
constructor(
|
constructor(
|
||||||
protected responseCache: ResponseCacheService,
|
protected responseCache: ResponseCacheService,
|
||||||
protected requestService: RequestService,
|
protected requestService: RequestService,
|
||||||
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig) {
|
protected halService: HALEndpointService) {
|
||||||
super();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getBrowseURLFor(metadatumKey: string, linkPath: string): Observable<string> {
|
getBrowseURLFor(metadatumKey: string, linkPath: string): Observable<string> {
|
||||||
const searchKeyArray = BrowseService.toSearchKeyArray(metadatumKey);
|
const searchKeyArray = BrowseService.toSearchKeyArray(metadatumKey);
|
||||||
return this.getEndpoint()
|
return this.halService.getEndpoint(linkPath)
|
||||||
.filter((href: string) => isNotEmpty(href))
|
.filter((href: string) => isNotEmpty(href))
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.map((endpointURL: string) => new BrowseEndpointRequest(this.requestService.generateRequestId(), endpointURL))
|
.map((endpointURL: string) => new BrowseEndpointRequest(this.requestService.generateRequestId(), endpointURL))
|
||||||
|
23
src/app/core/cache/response-cache.models.ts
vendored
23
src/app/core/cache/response-cache.models.ts
vendored
@@ -3,6 +3,7 @@ import { RequestError } from '../data/request.models';
|
|||||||
import { PageInfo } from '../shared/page-info.model';
|
import { PageInfo } from '../shared/page-info.model';
|
||||||
import { BrowseDefinition } from '../shared/browse-definition.model';
|
import { BrowseDefinition } from '../shared/browse-definition.model';
|
||||||
import { ConfigObject } from '../shared/config/config.model';
|
import { ConfigObject } from '../shared/config/config.model';
|
||||||
|
import { FacetValue } from '../../+search-page/search-service/facet-value.model';
|
||||||
|
|
||||||
/* tslint:disable:max-classes-per-file */
|
/* tslint:disable:max-classes-per-file */
|
||||||
export class RestResponse {
|
export class RestResponse {
|
||||||
@@ -32,6 +33,28 @@ export class SearchSuccessResponse extends RestResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class FacetValueMap {
|
||||||
|
[name: string]: FacetValueSuccessResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FacetValueSuccessResponse extends RestResponse {
|
||||||
|
constructor(
|
||||||
|
public results: FacetValue[],
|
||||||
|
public statusCode: string,
|
||||||
|
public pageInfo?: PageInfo) {
|
||||||
|
super(true, statusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FacetValueMapSuccessResponse extends RestResponse {
|
||||||
|
constructor(
|
||||||
|
public results: FacetValueMap,
|
||||||
|
public statusCode: string,
|
||||||
|
) {
|
||||||
|
super(true, statusCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class EndpointMap {
|
export class EndpointMap {
|
||||||
[linkPath: string]: string
|
[linkPath: string]: string
|
||||||
}
|
}
|
||||||
|
@@ -1,10 +1,7 @@
|
|||||||
import { Injectable } from '@angular/core';
|
|
||||||
|
|
||||||
import { Observable } from 'rxjs/Observable';
|
import { Observable } from 'rxjs/Observable';
|
||||||
|
|
||||||
import { RequestService } from '../data/request.service';
|
import { RequestService } from '../data/request.service';
|
||||||
import { ResponseCacheService } from '../cache/response-cache.service';
|
import { ResponseCacheService } from '../cache/response-cache.service';
|
||||||
import { GlobalConfig } from '../../../config/global-config.interface';
|
|
||||||
import { ConfigSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models';
|
import { ConfigSuccessResponse, ErrorResponse, RestResponse } from '../cache/response-cache.models';
|
||||||
import { ConfigRequest, FindAllOptions, RestRequest } from '../data/request.models';
|
import { ConfigRequest, FindAllOptions, RestRequest } from '../data/request.models';
|
||||||
import { ResponseCacheEntry } from '../cache/response-cache.reducer';
|
import { ResponseCacheEntry } from '../cache/response-cache.reducer';
|
||||||
@@ -12,13 +9,13 @@ import { hasValue, isNotEmpty } from '../../shared/empty.util';
|
|||||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||||
import { ConfigData } from './config-data';
|
import { ConfigData } from './config-data';
|
||||||
|
|
||||||
export abstract class ConfigService extends HALEndpointService {
|
export abstract class ConfigService {
|
||||||
protected request: ConfigRequest;
|
protected request: ConfigRequest;
|
||||||
protected abstract responseCache: ResponseCacheService;
|
protected abstract responseCache: ResponseCacheService;
|
||||||
protected abstract requestService: RequestService;
|
protected abstract requestService: RequestService;
|
||||||
protected abstract linkPath: string;
|
protected abstract linkPath: string;
|
||||||
protected abstract EnvConfig: GlobalConfig;
|
|
||||||
protected abstract browseEndpoint: string;
|
protected abstract browseEndpoint: string;
|
||||||
|
protected abstract halService: HALEndpointService;
|
||||||
|
|
||||||
protected getConfig(request: RestRequest): Observable<ConfigData> {
|
protected getConfig(request: RestRequest): Observable<ConfigData> {
|
||||||
const [successResponse, errorResponse] = this.responseCache.get(request.href)
|
const [successResponse, errorResponse] = this.responseCache.get(request.href)
|
||||||
@@ -68,7 +65,7 @@ export abstract class ConfigService extends HALEndpointService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getConfigAll(): Observable<ConfigData> {
|
public getConfigAll(): Observable<ConfigData> {
|
||||||
return this.getEndpoint()
|
return this.halService.getEndpoint(this.linkPath)
|
||||||
.filter((href: string) => isNotEmpty(href))
|
.filter((href: string) => isNotEmpty(href))
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.map((endpointURL: string) => new ConfigRequest(this.requestService.generateRequestId(), endpointURL))
|
.map((endpointURL: string) => new ConfigRequest(this.requestService.generateRequestId(), endpointURL))
|
||||||
@@ -85,7 +82,7 @@ export abstract class ConfigService extends HALEndpointService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getConfigByName(name: string): Observable<ConfigData> {
|
public getConfigByName(name: string): Observable<ConfigData> {
|
||||||
return this.getEndpoint()
|
return this.halService.getEndpoint(this.linkPath)
|
||||||
.map((endpoint: string) => this.getConfigByNameHref(endpoint, name))
|
.map((endpoint: string) => this.getConfigByNameHref(endpoint, name))
|
||||||
.filter((href: string) => isNotEmpty(href))
|
.filter((href: string) => isNotEmpty(href))
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
@@ -96,7 +93,7 @@ export abstract class ConfigService extends HALEndpointService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getConfigBySearch(options: FindAllOptions = {}): Observable<ConfigData> {
|
public getConfigBySearch(options: FindAllOptions = {}): Observable<ConfigData> {
|
||||||
return this.getEndpoint()
|
return this.halService.getEndpoint(this.linkPath)
|
||||||
.map((endpoint: string) => this.getConfigSearchHref(endpoint, options))
|
.map((endpoint: string) => this.getConfigSearchHref(endpoint, options))
|
||||||
.filter((href: string) => isNotEmpty(href))
|
.filter((href: string) => isNotEmpty(href))
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
|
@@ -1,10 +1,9 @@
|
|||||||
import { Inject, Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
import { ConfigService } from './config.service';
|
import { ConfigService } from './config.service';
|
||||||
import { ResponseCacheService } from '../cache/response-cache.service';
|
import { ResponseCacheService } from '../cache/response-cache.service';
|
||||||
import { RequestService } from '../data/request.service';
|
import { RequestService } from '../data/request.service';
|
||||||
import { GLOBAL_CONFIG } from '../../../config';
|
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||||
import { GlobalConfig } from '../../../config/global-config.interface';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SubmissionDefinitionsConfigService extends ConfigService {
|
export class SubmissionDefinitionsConfigService extends ConfigService {
|
||||||
@@ -14,7 +13,7 @@ export class SubmissionDefinitionsConfigService extends ConfigService {
|
|||||||
constructor(
|
constructor(
|
||||||
protected responseCache: ResponseCacheService,
|
protected responseCache: ResponseCacheService,
|
||||||
protected requestService: RequestService,
|
protected requestService: RequestService,
|
||||||
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig) {
|
protected halService: HALEndpointService) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,10 +1,9 @@
|
|||||||
import { Inject, Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
import { ConfigService } from './config.service';
|
import { ConfigService } from './config.service';
|
||||||
import { ResponseCacheService } from '../cache/response-cache.service';
|
import { ResponseCacheService } from '../cache/response-cache.service';
|
||||||
import { RequestService } from '../data/request.service';
|
import { RequestService } from '../data/request.service';
|
||||||
import { GLOBAL_CONFIG } from '../../../config';
|
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||||
import { GlobalConfig } from '../../../config/global-config.interface';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SubmissionFormsConfigService extends ConfigService {
|
export class SubmissionFormsConfigService extends ConfigService {
|
||||||
@@ -14,7 +13,7 @@ export class SubmissionFormsConfigService extends ConfigService {
|
|||||||
constructor(
|
constructor(
|
||||||
protected responseCache: ResponseCacheService,
|
protected responseCache: ResponseCacheService,
|
||||||
protected requestService: RequestService,
|
protected requestService: RequestService,
|
||||||
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig) {
|
protected halService: HALEndpointService) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,10 +1,9 @@
|
|||||||
import { Inject, Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
import { ConfigService } from './config.service';
|
import { ConfigService } from './config.service';
|
||||||
import { ResponseCacheService } from '../cache/response-cache.service';
|
import { ResponseCacheService } from '../cache/response-cache.service';
|
||||||
import { RequestService } from '../data/request.service';
|
import { RequestService } from '../data/request.service';
|
||||||
import { GLOBAL_CONFIG } from '../../../config';
|
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||||
import { GlobalConfig } from '../../../config/global-config.interface';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SubmissionSectionsConfigService extends ConfigService {
|
export class SubmissionSectionsConfigService extends ConfigService {
|
||||||
@@ -14,7 +13,7 @@ export class SubmissionSectionsConfigService extends ConfigService {
|
|||||||
constructor(
|
constructor(
|
||||||
protected responseCache: ResponseCacheService,
|
protected responseCache: ResponseCacheService,
|
||||||
protected requestService: RequestService,
|
protected requestService: RequestService,
|
||||||
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig) {
|
protected halService: HALEndpointService) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -40,6 +40,8 @@ import { SubmissionDefinitionsConfigService } from './config/submission-definiti
|
|||||||
import { SubmissionFormsConfigService } from './config/submission-forms-config.service';
|
import { SubmissionFormsConfigService } from './config/submission-forms-config.service';
|
||||||
import { SubmissionSectionsConfigService } from './config/submission-sections-config.service';
|
import { SubmissionSectionsConfigService } from './config/submission-sections-config.service';
|
||||||
import { UUIDService } from './shared/uuid.service';
|
import { UUIDService } from './shared/uuid.service';
|
||||||
|
import { HALEndpointService } from './shared/hal-endpoint.service';
|
||||||
|
import { FacetResponseParsingService } from './data/facet-response-parsing.service';
|
||||||
|
|
||||||
const IMPORTS = [
|
const IMPORTS = [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
@@ -61,6 +63,7 @@ const PROVIDERS = [
|
|||||||
CollectionDataService,
|
CollectionDataService,
|
||||||
DSOResponseParsingService,
|
DSOResponseParsingService,
|
||||||
DSpaceRESTv2Service,
|
DSpaceRESTv2Service,
|
||||||
|
HALEndpointService,
|
||||||
HostWindowService,
|
HostWindowService,
|
||||||
ItemDataService,
|
ItemDataService,
|
||||||
MetadataService,
|
MetadataService,
|
||||||
@@ -70,6 +73,7 @@ const PROVIDERS = [
|
|||||||
RequestService,
|
RequestService,
|
||||||
ResponseCacheService,
|
ResponseCacheService,
|
||||||
EndpointMapResponseParsingService,
|
EndpointMapResponseParsingService,
|
||||||
|
FacetResponseParsingService,
|
||||||
DebugResponseParsingService,
|
DebugResponseParsingService,
|
||||||
SearchResponseParsingService,
|
SearchResponseParsingService,
|
||||||
ServerResponseService,
|
ServerResponseService,
|
||||||
|
@@ -10,6 +10,7 @@ import { Collection } from '../shared/collection.model';
|
|||||||
import { ComColDataService } from './comcol-data.service';
|
import { ComColDataService } from './comcol-data.service';
|
||||||
import { CommunityDataService } from './community-data.service';
|
import { CommunityDataService } from './community-data.service';
|
||||||
import { RequestService } from './request.service';
|
import { RequestService } from './request.service';
|
||||||
|
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CollectionDataService extends ComColDataService<NormalizedCollection, Collection> {
|
export class CollectionDataService extends ComColDataService<NormalizedCollection, Collection> {
|
||||||
@@ -20,9 +21,9 @@ export class CollectionDataService extends ComColDataService<NormalizedCollectio
|
|||||||
protected requestService: RequestService,
|
protected requestService: RequestService,
|
||||||
protected rdbService: RemoteDataBuildService,
|
protected rdbService: RemoteDataBuildService,
|
||||||
protected store: Store<CoreState>,
|
protected store: Store<CoreState>,
|
||||||
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
|
|
||||||
protected cds: CommunityDataService,
|
protected cds: CommunityDataService,
|
||||||
protected objectCache: ObjectCacheService
|
protected objectCache: ObjectCacheService,
|
||||||
|
protected halService: HALEndpointService
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
@@ -10,10 +10,12 @@ import { CommunityDataService } from './community-data.service';
|
|||||||
import { DataService } from './data.service';
|
import { DataService } from './data.service';
|
||||||
import { FindByIDRequest } from './request.models';
|
import { FindByIDRequest } from './request.models';
|
||||||
import { NormalizedObject } from '../cache/models/normalized-object.model';
|
import { NormalizedObject } from '../cache/models/normalized-object.model';
|
||||||
|
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||||
|
|
||||||
export abstract class ComColDataService<TNormalized extends NormalizedObject, TDomain> extends DataService<TNormalized, TDomain> {
|
export abstract class ComColDataService<TNormalized extends NormalizedObject, TDomain> extends DataService<TNormalized, TDomain> {
|
||||||
protected abstract cds: CommunityDataService;
|
protected abstract cds: CommunityDataService;
|
||||||
protected abstract objectCache: ObjectCacheService;
|
protected abstract objectCache: ObjectCacheService;
|
||||||
|
protected abstract halService: HALEndpointService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the scoped endpoint URL by fetching the object with
|
* Get the scoped endpoint URL by fetching the object with
|
||||||
@@ -27,7 +29,7 @@ export abstract class ComColDataService<TNormalized extends NormalizedObject, TD
|
|||||||
*/
|
*/
|
||||||
public getScopedEndpoint(scopeID: string): Observable<string> {
|
public getScopedEndpoint(scopeID: string): Observable<string> {
|
||||||
if (isEmpty(scopeID)) {
|
if (isEmpty(scopeID)) {
|
||||||
return this.getEndpoint();
|
return this.halService.getEndpoint(this.linkPath);
|
||||||
} else {
|
} else {
|
||||||
const scopeCommunityHrefObs = this.cds.getEndpoint()
|
const scopeCommunityHrefObs = this.cds.getEndpoint()
|
||||||
.flatMap((endpoint: string) => this.cds.getFindByIDHref(endpoint, scopeID))
|
.flatMap((endpoint: string) => this.cds.getFindByIDHref(endpoint, scopeID))
|
||||||
|
@@ -10,6 +10,7 @@ import { CoreState } from '../core.reducers';
|
|||||||
import { Community } from '../shared/community.model';
|
import { Community } from '../shared/community.model';
|
||||||
import { ComColDataService } from './comcol-data.service';
|
import { ComColDataService } from './comcol-data.service';
|
||||||
import { RequestService } from './request.service';
|
import { RequestService } from './request.service';
|
||||||
|
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CommunityDataService extends ComColDataService<NormalizedCommunity, Community> {
|
export class CommunityDataService extends ComColDataService<NormalizedCommunity, Community> {
|
||||||
@@ -21,9 +22,13 @@ export class CommunityDataService extends ComColDataService<NormalizedCommunity,
|
|||||||
protected requestService: RequestService,
|
protected requestService: RequestService,
|
||||||
protected rdbService: RemoteDataBuildService,
|
protected rdbService: RemoteDataBuildService,
|
||||||
protected store: Store<CoreState>,
|
protected store: Store<CoreState>,
|
||||||
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
|
protected objectCache: ObjectCacheService,
|
||||||
protected objectCache: ObjectCacheService
|
protected halService: HALEndpointService
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getEndpoint() {
|
||||||
|
return this.halService.getEndpoint(this.linkPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,11 +1,9 @@
|
|||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { Observable } from 'rxjs/Observable';
|
import { Observable } from 'rxjs/Observable';
|
||||||
import { GlobalConfig } from '../../../config';
|
|
||||||
import { hasValue, isNotEmpty } from '../../shared/empty.util';
|
import { hasValue, isNotEmpty } from '../../shared/empty.util';
|
||||||
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||||
import { ResponseCacheService } from '../cache/response-cache.service';
|
import { ResponseCacheService } from '../cache/response-cache.service';
|
||||||
import { CoreState } from '../core.reducers';
|
import { CoreState } from '../core.reducers';
|
||||||
import { GenericConstructor } from '../shared/generic-constructor';
|
|
||||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||||
import { URLCombiner } from '../url-combiner/url-combiner';
|
import { URLCombiner } from '../url-combiner/url-combiner';
|
||||||
import { PaginatedList } from './paginated-list';
|
import { PaginatedList } from './paginated-list';
|
||||||
@@ -14,13 +12,13 @@ import { FindAllOptions, FindAllRequest, FindByIDRequest, GetRequest } from './r
|
|||||||
import { RequestService } from './request.service';
|
import { RequestService } from './request.service';
|
||||||
import { NormalizedObject } from '../cache/models/normalized-object.model';
|
import { NormalizedObject } from '../cache/models/normalized-object.model';
|
||||||
|
|
||||||
export abstract class DataService<TNormalized extends NormalizedObject, TDomain> extends HALEndpointService {
|
export abstract class DataService<TNormalized extends NormalizedObject, TDomain> {
|
||||||
protected abstract responseCache: ResponseCacheService;
|
protected abstract responseCache: ResponseCacheService;
|
||||||
protected abstract requestService: RequestService;
|
protected abstract requestService: RequestService;
|
||||||
protected abstract rdbService: RemoteDataBuildService;
|
protected abstract rdbService: RemoteDataBuildService;
|
||||||
protected abstract store: Store<CoreState>;
|
protected abstract store: Store<CoreState>;
|
||||||
protected abstract linkPath: string;
|
protected abstract linkPath: string;
|
||||||
protected abstract EnvConfig: GlobalConfig;
|
protected abstract halService: HALEndpointService;
|
||||||
|
|
||||||
public abstract getScopedEndpoint(scope: string): Observable<string>
|
public abstract getScopedEndpoint(scope: string): Observable<string>
|
||||||
|
|
||||||
@@ -55,7 +53,7 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain>
|
|||||||
}
|
}
|
||||||
|
|
||||||
findAll(options: FindAllOptions = {}): Observable<RemoteData<PaginatedList<TDomain>>> {
|
findAll(options: FindAllOptions = {}): Observable<RemoteData<PaginatedList<TDomain>>> {
|
||||||
const hrefObs = this.getEndpoint().filter((href: string) => isNotEmpty(href))
|
const hrefObs = this.halService.getEndpoint(this.linkPath).filter((href: string) => isNotEmpty(href))
|
||||||
.flatMap((endpoint: string) => this.getFindAllHref(endpoint, options));
|
.flatMap((endpoint: string) => this.getFindAllHref(endpoint, options));
|
||||||
|
|
||||||
hrefObs
|
hrefObs
|
||||||
@@ -74,7 +72,7 @@ export abstract class DataService<TNormalized extends NormalizedObject, TDomain>
|
|||||||
}
|
}
|
||||||
|
|
||||||
findById(id: string): Observable<RemoteData<TDomain>> {
|
findById(id: string): Observable<RemoteData<TDomain>> {
|
||||||
const hrefObs = this.getEndpoint()
|
const hrefObs = this.halService.getEndpoint(this.linkPath)
|
||||||
.map((endpoint: string) => this.getFindByIDHref(endpoint, id));
|
.map((endpoint: string) => this.getFindByIDHref(endpoint, id));
|
||||||
|
|
||||||
hrefObs
|
hrefObs
|
||||||
|
41
src/app/core/data/facet-response-parsing.service.ts
Normal file
41
src/app/core/data/facet-response-parsing.service.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import {
|
||||||
|
FacetValueMap,
|
||||||
|
FacetValueMapSuccessResponse,
|
||||||
|
FacetValueSuccessResponse,
|
||||||
|
RestResponse
|
||||||
|
} from '../cache/response-cache.models';
|
||||||
|
import { ResponseParsingService } from './parsing.service';
|
||||||
|
import { RestRequest } from './request.models';
|
||||||
|
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
|
||||||
|
import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer';
|
||||||
|
import { PageInfo } from '../shared/page-info.model';
|
||||||
|
import { isNotEmpty } from '../../shared/empty.util';
|
||||||
|
import { FacetValue } from '../../+search-page/search-service/facet-value.model';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FacetResponseParsingService implements ResponseParsingService {
|
||||||
|
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
|
||||||
|
|
||||||
|
const payload = data.payload;
|
||||||
|
const facetMap: FacetValueMap = new FacetValueMap();
|
||||||
|
|
||||||
|
const serializer = new DSpaceRESTv2Serializer(FacetValue);
|
||||||
|
payload._embedded.facets.map((facet) => {
|
||||||
|
const values = facet._embedded.values.map((value) => {value.search = value._links.search.href; return value;});
|
||||||
|
const facetValues = serializer.deserializeArray(values);
|
||||||
|
const valuesResponse = new FacetValueSuccessResponse(facetValues, data.statusCode, this.processPageInfo(data.payload.page));
|
||||||
|
facetMap[facet.name] = valuesResponse;
|
||||||
|
});
|
||||||
|
|
||||||
|
return new FacetValueMapSuccessResponse(facetMap, data.statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected processPageInfo(pageObj: any): PageInfo {
|
||||||
|
if (isNotEmpty(pageObj)) {
|
||||||
|
return new DSpaceRESTv2Serializer(PageInfo).deserialize(pageObj);
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -14,6 +14,7 @@ import { URLCombiner } from '../url-combiner/url-combiner';
|
|||||||
|
|
||||||
import { DataService } from './data.service';
|
import { DataService } from './data.service';
|
||||||
import { RequestService } from './request.service';
|
import { RequestService } from './request.service';
|
||||||
|
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ItemDataService extends DataService<NormalizedItem, Item> {
|
export class ItemDataService extends DataService<NormalizedItem, Item> {
|
||||||
@@ -24,15 +25,14 @@ export class ItemDataService extends DataService<NormalizedItem, Item> {
|
|||||||
protected requestService: RequestService,
|
protected requestService: RequestService,
|
||||||
protected rdbService: RemoteDataBuildService,
|
protected rdbService: RemoteDataBuildService,
|
||||||
protected store: Store<CoreState>,
|
protected store: Store<CoreState>,
|
||||||
@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
|
private bs: BrowseService,
|
||||||
private bs: BrowseService
|
protected halService: HALEndpointService) {
|
||||||
) {
|
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
public getScopedEndpoint(scopeID: string): Observable<string> {
|
public getScopedEndpoint(scopeID: string): Observable<string> {
|
||||||
if (isEmpty(scopeID)) {
|
if (isEmpty(scopeID)) {
|
||||||
return this.getEndpoint();
|
return this.halService.getEndpoint(this.linkPath);
|
||||||
} else {
|
} else {
|
||||||
return this.bs.getBrowseURLFor('dc.date.issued', this.linkPath)
|
return this.bs.getBrowseURLFor('dc.date.issued', this.linkPath)
|
||||||
.filter((href: string) => isNotEmpty(href))
|
.filter((href: string) => isNotEmpty(href))
|
||||||
|
@@ -8,13 +8,19 @@ import { EndpointMapRequest } from '../data/request.models';
|
|||||||
import { ResponseCacheEntry } from '../cache/response-cache.reducer';
|
import { ResponseCacheEntry } from '../cache/response-cache.reducer';
|
||||||
import { isEmpty, isNotEmpty } from '../../shared/empty.util';
|
import { isEmpty, isNotEmpty } from '../../shared/empty.util';
|
||||||
import { RESTURLCombiner } from '../url-combiner/rest-url-combiner';
|
import { RESTURLCombiner } from '../url-combiner/rest-url-combiner';
|
||||||
|
import { Inject, Injectable } from '@angular/core';
|
||||||
|
import { GLOBAL_CONFIG } from '../../../config';
|
||||||
|
|
||||||
export abstract class HALEndpointService {
|
@Injectable()
|
||||||
protected abstract responseCache: ResponseCacheService;
|
export class HALEndpointService {
|
||||||
protected abstract requestService: RequestService;
|
|
||||||
protected abstract linkPath: string;
|
|
||||||
protected abstract EnvConfig: GlobalConfig;
|
|
||||||
|
|
||||||
|
protected linkPath: string;
|
||||||
|
|
||||||
|
constructor(private responseCache: ResponseCacheService,
|
||||||
|
private requestService: RequestService,
|
||||||
|
@Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig) {
|
||||||
|
|
||||||
|
}
|
||||||
protected getRootHref(): string {
|
protected getRootHref(): string {
|
||||||
return new RESTURLCombiner(this.EnvConfig, '/').toString();
|
return new RESTURLCombiner(this.EnvConfig, '/').toString();
|
||||||
}
|
}
|
||||||
@@ -33,8 +39,8 @@ export abstract class HALEndpointService {
|
|||||||
.distinctUntilChanged();
|
.distinctUntilChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
public getEndpoint(): Observable<string> {
|
public getEndpoint(linkPath: string): Observable<string> {
|
||||||
return this.getEndpointAt(...this.linkPath.split('/'));
|
return this.getEndpointAt(...linkPath.split('/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
private getEndpointAt(...path: string[]): Observable<string> {
|
private getEndpointAt(...path: string[]): Observable<string> {
|
||||||
@@ -50,10 +56,10 @@ export abstract class HALEndpointService {
|
|||||||
return Observable.of(this.getRootHref()).pipe(...pipeArguments, distinctUntilChanged());
|
return Observable.of(this.getRootHref()).pipe(...pipeArguments, distinctUntilChanged());
|
||||||
}
|
}
|
||||||
|
|
||||||
public isEnabledOnRestApi(): Observable<boolean> {
|
public isEnabledOnRestApi(linkPath: string): Observable<boolean> {
|
||||||
return this.getRootEndpointMap().pipe(
|
return this.getRootEndpointMap().pipe(
|
||||||
// TODO this only works when there's no / in linkPath
|
// TODO this only works when there's no / in linkPath
|
||||||
map((endpointMap: EndpointMap) => isNotEmpty(endpointMap[this.linkPath])),
|
map((endpointMap: EndpointMap) => isNotEmpty(endpointMap[linkPath])),
|
||||||
startWith(undefined),
|
startWith(undefined),
|
||||||
distinctUntilChanged()
|
distinctUntilChanged()
|
||||||
)
|
)
|
||||||
|
Reference in New Issue
Block a user