Merge branch 'w2p-53881_facet-search-box' into search-features

Conflicts:
	src/app/core/shared/resource-type.ts
	src/app/shared/shared.module.ts
This commit is contained in:
lotte
2018-07-12 14:54:42 +02:00
46 changed files with 1230 additions and 384 deletions

View File

@@ -8,12 +8,18 @@ module.exports = {
nameSpace: '/' nameSpace: '/'
}, },
// The REST API server settings. // The REST API server settings.
// rest: {
// ssl: true,
// host: 'dspace7.4science.it',
// port: 443,
// // NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript
// nameSpace: '/dspace-spring-rest/api'
// },
rest: { rest: {
ssl: true, ssl: false,
host: 'dspace7.4science.it', host: 'dspace7-internal.atmire.com',
port: 443, port: 443,
// NOTE: Space is capitalized because 'namespace' is a reserved string in TypeScript nameSpace: '/rest/api'
nameSpace: '/dspace-spring-rest/api'
}, },
// Caching settings // Caching settings
cache: { cache: {

View File

@@ -2,7 +2,7 @@
<div *ngIf="subCollectionsRD?.hasSucceeded" @fadeIn> <div *ngIf="subCollectionsRD?.hasSucceeded" @fadeIn>
<h2>{{'community.sub-collection-list.head' | translate}}</h2> <h2>{{'community.sub-collection-list.head' | translate}}</h2>
<ul> <ul>
<li *ngFor="let collection of subCollectionsRD?.payload"> <li *ngFor="let collection of subCollectionsRD?.payload.page">
<p> <p>
<span class="lead"><a [routerLink]="['/collections', collection.id]">{{collection.name}}</a></span><br> <span class="lead"><a [routerLink]="['/collections', collection.id]">{{collection.name}}</a></span><br>
<span class="text-muted">{{collection.shortDescription}}</span> <span class="text-muted">{{collection.shortDescription}}</span>

View File

@@ -6,6 +6,7 @@ import { Collection } from '../../core/shared/collection.model';
import { Community } from '../../core/shared/community.model'; import { Community } from '../../core/shared/community.model';
import { fadeIn } from '../../shared/animations/fade'; import { fadeIn } from '../../shared/animations/fade';
import { PaginatedList } from '../../core/data/paginated-list';
@Component({ @Component({
selector: 'ds-community-page-sub-collection-list', selector: 'ds-community-page-sub-collection-list',
@@ -15,7 +16,7 @@ import { fadeIn } from '../../shared/animations/fade';
}) })
export class CommunityPageSubCollectionListComponent implements OnInit { export class CommunityPageSubCollectionListComponent implements OnInit {
@Input() community: Community; @Input() community: Community;
subCollectionsRDObs: Observable<RemoteData<Collection[]>>; subCollectionsRDObs: Observable<RemoteData<PaginatedList<Collection>>>;
ngOnInit(): void { ngOnInit(): void {
this.subCollectionsRDObs = this.community.collections; this.subCollectionsRDObs = this.community.collections;

View File

@@ -1,4 +1,4 @@
<ds-metadata-field-wrapper [label]="label | translate"> <ds-metadata-field-wrapper *ngIf="hasSucceeded() | async" [label]="label | translate">
<div class="collections"> <div class="collections">
<a *ngFor="let collection of (collections | async); let last=last;" [routerLink]="['/collections', collection.id]"> <a *ngFor="let collection of (collections | async); let last=last;" [routerLink]="['/collections', collection.id]">
<span>{{collection?.name}}</span><span *ngIf="!last" [innerHTML]="separator"></span> <span>{{collection?.name}}</span><span *ngIf="!last" [innerHTML]="separator"></span>

View File

@@ -0,0 +1,74 @@
import { CollectionsComponent } from './collections.component';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { By } from '@angular/platform-browser';
import { Collection } from '../../../core/shared/collection.model';
import { RemoteDataBuildService } from '../../../core/cache/builders/remote-data-build.service';
import { getMockRemoteDataBuildService } from '../../../shared/mocks/mock-remote-data-build.service';
import { Item } from '../../../core/shared/item.model';
import { Observable } from 'rxjs/Observable';
import { RemoteData } from '../../../core/data/remote-data';
import { TranslateModule } from '@ngx-translate/core';
let collectionsComponent: CollectionsComponent;
let fixture: ComponentFixture<CollectionsComponent>;
const mockCollection1: Collection = Object.assign(new Collection(), {
metadata: [
{
key: 'dc.description.abstract',
language: 'en_US',
value: 'Short description'
}]
});
const succeededMockItem: Item = Object.assign(new Item(), {owningCollection: Observable.of(new RemoteData(false, false, true, null, mockCollection1))});
const failedMockItem: Item = Object.assign(new Item(), {owningCollection: Observable.of(new RemoteData(false, false, false, null, mockCollection1))});
describe('CollectionsComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
declarations: [ CollectionsComponent ],
providers: [
{ provide: RemoteDataBuildService, useValue: getMockRemoteDataBuildService()}
],
schemas: [ NO_ERRORS_SCHEMA ]
}).overrideComponent(CollectionsComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default }
}).compileComponents();
}));
beforeEach(async(() => {
fixture = TestBed.createComponent(CollectionsComponent);
collectionsComponent = fixture.componentInstance;
collectionsComponent.label = 'test.test';
collectionsComponent.separator = '<br/>';
}));
describe('When the requested item request has succeeded', () => {
beforeEach(() => {
collectionsComponent.item = succeededMockItem;
fixture.detectChanges();
});
it('should show the collection', () => {
const collectionField = fixture.debugElement.query(By.css('ds-metadata-field-wrapper div.collections'));
expect(collectionField).not.toBeNull();
});
});
describe('When the requested item request has succeeded', () => {
beforeEach(() => {
collectionsComponent.item = failedMockItem;
fixture.detectChanges();
});
it('should not show the collection', () => {
const collectionField = fixture.debugElement.query(By.css('ds-metadata-field-wrapper div.collections'));
expect(collectionField).toBeNull();
});
});
});

View File

@@ -38,4 +38,8 @@ export class CollectionsComponent implements OnInit {
this.collections = this.item.owner.map((rd: RemoteData<Collection>) => [rd.payload]); this.collections = this.item.owner.map((rd: RemoteData<Collection>) => [rd.payload]);
} }
hasSucceeded() {
return this.item.owner.map((rd: RemoteData<Collection>) => rd.hasSucceeded);
}
} }

View File

@@ -1,7 +1,6 @@
import { SortOptions } from '../core/cache/models/sort-options.model'; import { SortOptions } from '../core/cache/models/sort-options.model';
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
import { isNotEmpty } from '../shared/empty.util'; import { isNotEmpty } from '../shared/empty.util';
import { URLCombiner } from '../core/url-combiner/url-combiner';
import { SearchOptions } from './search-options.model'; import { SearchOptions } from './search-options.model';
export class PaginatedSearchOptions extends SearchOptions { export class PaginatedSearchOptions extends SearchOptions {

View File

@@ -1,19 +1,19 @@
<div> <div>
<div class="filters"> <div class="filters py-2">
<a *ngFor="let value of selectedValues" class="d-block" <a *ngFor="let value of selectedValues" class="d-flex flex-row"
[routerLink]="[getSearchLink()]" [routerLink]="[getSearchLink()]"
[queryParams]="getRemoveParams(value)" queryParamsHandling="merge"> [queryParams]="getRemoveParams(value)" queryParamsHandling="merge">
<input type="checkbox" [checked]="true"/> <input type="checkbox" [checked]="true" class="my-1 align-self-stretch"/>
<span class="filter-value">{{value}}</span> <span class="filter-value pl-1">{{value}}</span>
</a> </a>
<ng-container *ngFor="let page of (filterValues$ | async)"> <ng-container *ngFor="let page of (filterValues$ | async)">
<ng-container *ngFor="let value of (page | async)?.payload.page; let i=index"> <ng-container *ngFor="let value of (page | async)?.payload.page; let i=index">
<a *ngIf="!selectedValues.includes(value.value)" class="d-block clearfix" <a *ngIf="!selectedValues.includes(value.value)" class="d-flex flex-row"
[routerLink]="[getSearchLink()]" [routerLink]="[getSearchLink()]"
[queryParams]="getAddParams(value.value)" queryParamsHandling="merge"> [queryParams]="getAddParams(value.value)" queryParamsHandling="merge">
<input type="checkbox" [checked]="false"/> <input type="checkbox" [checked]="false" class="my-1 align-self-stretch"/>
<span class="filter-value">{{value.value}}</span> <span class="filter-value px-1">{{value.value}}</span>
<span class="float-right filter-value-count"> <span class="float-right filter-value-count ml-auto">
<span class="badge badge-secondary badge-pill">{{value.count}}</span> <span class="badge badge-secondary badge-pill">{{value.count}}</span>
</span> </span>
</a> </a>
@@ -28,11 +28,14 @@
| translate}}</a> | translate}}</a>
</div> </div>
</div> </div>
<form #form="ngForm" (ngSubmit)="onSubmit(form.value)" class="add-filter" <ds-input-suggestions [suggestions]="(filterSearchResults | async)"
[action]="getCurrentUrl()"> [placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate"
<input type="text" [(ngModel)]="filter" [name]="filterConfig.paramName" class="form-control" [action]="getCurrentUrl()"
aria-label="New filter input" [name]="filterConfig.paramName"
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate" [ngModelOptions]="{standalone: true}"/> [(ngModel)]="filter"
<input type="submit" class="d-none"/> (submitSuggestion)="onSubmit($event)"
</form> (clickSuggestion)="onClick($event)"
(findSuggestions)="findSuggestions($event)"
ngDefaultControl
></ds-input-suggestions>
</div> </div>

View File

@@ -2,13 +2,14 @@
@import '../../../../../styles/mixins.scss'; @import '../../../../../styles/mixins.scss';
.filters { .filters {
margin-top: $spacer/2;
margin-bottom: $spacer/2;
a { a {
color: $body-color; color: $body-color;
&:hover { &:hover, &focus {
text-decoration: none; text-decoration: none;
} }
span.badge {
vertical-align: text-top;
}
} }
.toggle-more-filters a { .toggle-more-filters a {
color: $link-color; color: $link-color;
@@ -16,3 +17,7 @@
cursor: pointer; cursor: pointer;
} }
} }
::ng-deep em {
font-weight: bold;
font-style: normal;
}

View File

@@ -1,6 +1,5 @@
import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { SearchFacetFilterComponent } from './search-facet-filter.component'; import { SearchFacetFilterComponent } from './search-facet-filter.component';
@@ -182,7 +181,7 @@ describe('SearchFacetFilterComponent', () => {
describe('when the onSubmit method is called with data', () => { describe('when the onSubmit method is called with data', () => {
const searchUrl = '/search/path'; const searchUrl = '/search/path';
const testValue = 'test'; const testValue = 'test';
const data = { [mockFilterConfig.paramName]: testValue }; const data = testValue ;
beforeEach(() => { beforeEach(() => {
spyOn(comp, 'getSearchLink').and.returnValue(searchUrl); spyOn(comp, 'getSearchLink').and.returnValue(searchUrl);
comp.onSubmit(data); comp.onSubmit(data);
@@ -239,4 +238,14 @@ describe('SearchFacetFilterComponent', () => {
expect(comp.showFirstPageOnly).toHaveBeenCalled(); expect(comp.showFirstPageOnly).toHaveBeenCalled();
}); });
}); });
describe('when findSuggestions is called with query \'test\'', () => {
const query = 'test';
beforeEach(() => {
comp.findSuggestions(query);
});
it('should call getFacetValuesFor on the component\'s SearchService with the right query', () => {
expect((comp as any).searchService.getFacetValuesFor).toHaveBeenCalledWith(comp.filterConfig, 1, {}, query);
});
});
}); });

View File

@@ -1,4 +1,12 @@
import { Component, Input, OnDestroy, OnInit } from '@angular/core'; import {
Component,
ElementRef,
Input,
OnDestroy,
OnInit, QueryList,
ViewChild,
ViewChildren
} from '@angular/core';
import { FacetValue } from '../../../search-service/facet-value.model'; import { FacetValue } from '../../../search-service/facet-value.model';
import { SearchFilterConfig } from '../../../search-service/search-filter-config.model'; import { SearchFilterConfig } from '../../../search-service/search-filter-config.model';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
@@ -11,6 +19,7 @@ import { SearchService } from '../../../search-service/search.service';
import { SearchOptions } from '../../../search-options.model'; import { SearchOptions } from '../../../search-options.model';
import { BehaviorSubject } from 'rxjs/BehaviorSubject'; import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import { Subscription } from 'rxjs/Subscription'; import { Subscription } from 'rxjs/Subscription';
import { EmphasizePipe } from '../../../../shared/utils/emphasize.pipe';
/** /**
* This component renders a simple item page. * This component renders a simple item page.
@@ -34,6 +43,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
filter: string; filter: string;
pageChange = false; pageChange = false;
sub: Subscription; sub: Subscription;
filterSearchResults: Observable<any[]> = Observable.of([]);
constructor(private searchService: SearchService, private filterService: SearchFilterService, private router: Router) { constructor(private searchService: SearchService, private filterService: SearchFilterService, private router: Router) {
} }
@@ -58,6 +68,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
this.filterValues$.next(this.filterValues); this.filterValues$.next(this.filterValues);
newValues$.first().subscribe((rd) => this.isLastPage$.next(hasNoValue(rd.payload.next))); newValues$.first().subscribe((rd) => this.isLastPage$.next(hasNoValue(rd.payload.next)));
}); });
this.filter = '';
} }
isChecked(value: FacetValue): Observable<boolean> { isChecked(value: FacetValue): Observable<boolean> {
@@ -89,16 +100,22 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
if (isNotEmpty(data)) { if (isNotEmpty(data)) {
this.router.navigate([this.getSearchLink()], { this.router.navigate([this.getSearchLink()], {
queryParams: queryParams:
{ [this.filterConfig.paramName]: [...this.selectedValues, data[this.filterConfig.paramName]] }, { [this.filterConfig.paramName]: [...this.selectedValues, data] },
queryParamsHandling: 'merge' queryParamsHandling: 'merge'
}); });
this.filter = ''; this.filter = '';
} }
this.filterSearchResults = Observable.of([]);
}
onClick(data: any) {
this.filter = data;
} }
hasValue(o: any): boolean { hasValue(o: any): boolean {
return hasValue(o); return hasValue(o);
} }
getRemoveParams(value: string) { getRemoveParams(value: string) {
return { return {
[this.filterConfig.paramName]: this.selectedValues.filter((v) => v !== value), [this.filterConfig.paramName]: this.selectedValues.filter((v) => v !== value),
@@ -122,4 +139,29 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
this.sub.unsubscribe(); this.sub.unsubscribe();
} }
} }
findSuggestions(data): void {
if (isNotEmpty(data)) {
this.filterService.getSearchOptions().first().subscribe(
(options) => {
this.filterSearchResults = this.searchService.getFacetValuesFor(this.filterConfig, 1, options, data.toLowerCase())
.first()
.map(
(rd: RemoteData<PaginatedList<FacetValue>>) => {
return rd.payload.page.map((facet) => {
return {displayValue: this.getDisplayValue(facet, data), value: facet.value}
})
}
);
}
)
} else {
this.filterSearchResults = Observable.of([]);
}
}
getDisplayValue(facet: FacetValue, query: string): string {
return new EmphasizePipe().transform(facet.value, query) + ' (' + facet.count + ')';
}
} }

View File

@@ -1,7 +1,7 @@
<div> <div>
<div (click)="toggle()" class="filter-name"><h5 class="d-inline-block mb-0">{{'search.filters.filter.' + filter.name + '.head'| translate}}</h5> <span class="filter-toggle fa float-right" <div (click)="toggle()" class="filter-name"><h5 class="d-inline-block mb-0">{{'search.filters.filter.' + filter.name + '.head'| translate}}</h5> <span class="filter-toggle fa float-right"
[ngClass]="(isCollapsed() | async) ? 'fa-plus' : 'fa-minus'"></span></div> [ngClass]="(isCollapsed() | async) ? 'fa-plus' : 'fa-minus'"></span></div>
<div [@slide]="(isCollapsed() | async) ? 'collapsed' : 'expanded'" class="search-filter-wrapper"> <div [@slide]="(isCollapsed() | async) ? 'collapsed' : 'expanded'" (@slide.start)="startSlide($event)" (@slide.done)="finishSlide($event)" class="search-filter-wrapper" [ngClass]="{closed: collapsed}">
<ds-search-facet-filter [filterConfig]="filter" [selectedValues]="getSelectedValues() | async"></ds-search-facet-filter> <ds-search-facet-filter [filterConfig]="filter" [selectedValues]="getSelectedValues() | async"></ds-search-facet-filter>
</div> </div>
</div> </div>

View File

@@ -3,7 +3,7 @@
:host { :host {
border: 1px solid map-get($theme-colors, light); border: 1px solid map-get($theme-colors, light);
.search-filter-wrapper { .search-filter-wrapper.closed {
overflow: hidden; overflow: hidden;
} }
.filter-toggle { .filter-toggle {

View File

@@ -1,12 +1,8 @@
import { Component, Input, OnInit } from '@angular/core'; import { Component, Input, OnInit } from '@angular/core';
import { SearchFilterConfig } from '../../search-service/search-filter-config.model'; import { SearchFilterConfig } from '../../search-service/search-filter-config.model';
import { SearchService } from '../../search-service/search.service';
import { RemoteData } from '../../../core/data/remote-data';
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.
@@ -23,6 +19,7 @@ import { PaginatedList } from '../../../core/data/paginated-list';
export class SearchFilterComponent implements OnInit { export class SearchFilterComponent implements OnInit {
@Input() filter: SearchFilterConfig; @Input() filter: SearchFilterConfig;
collapsed;
constructor(private filterService: SearchFilterService) { constructor(private filterService: SearchFilterService) {
} }
@@ -47,13 +44,27 @@ export class SearchFilterComponent implements OnInit {
initialCollapse() { initialCollapse() {
this.filterService.initialCollapse(this.filter.name); this.filterService.initialCollapse(this.filter.name);
this.collapsed = true;
} }
initialExpand() { initialExpand() {
this.filterService.initialExpand(this.filter.name); this.filterService.initialExpand(this.filter.name);
this.collapsed = false;
} }
getSelectedValues(): Observable<string[]> { getSelectedValues(): Observable<string[]> {
return this.filterService.getSelectedValuesForFilter(this.filter); return this.filterService.getSelectedValuesForFilter(this.filter);
} }
finishSlide(event: any): void {
if (event.fromState === 'collapsed') {
this.collapsed = false;
}
}
startSlide(event: any): void {
if (event.toState === 'collapsed') {
this.collapsed = true;
}
}
} }

View File

@@ -24,7 +24,7 @@ export class SearchOptions {
} }
if (isNotEmpty(this.filters)) { if (isNotEmpty(this.filters)) {
Object.entries(this.filters).forEach(([key, values]) => { Object.entries(this.filters).forEach(([key, values]) => {
values.forEach((value) => args.push(`${key}=${value},equals`)); values.forEach((value) => args.push(`${key}=${value},query`));
}); });
} }
if (isNotEmpty(args)) { if (isNotEmpty(args)) {

View File

@@ -175,13 +175,18 @@ export class SearchService implements OnDestroy {
return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, facetConfigObs); return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, facetConfigObs);
} }
getFacetValuesFor(filterConfig: SearchFilterConfig, valuePage: number, searchOptions?: SearchOptions): Observable<RemoteData<PaginatedList<FacetValue>>> { getFacetValuesFor(filterConfig: SearchFilterConfig, valuePage: number, searchOptions?: SearchOptions, filterQuery?: string): Observable<RemoteData<PaginatedList<FacetValue>>> {
const requestObs = this.halService.getEndpoint(this.facetValueLinkPathPrefix + filterConfig.name).pipe( const requestObs = this.halService.getEndpoint(this.facetValueLinkPathPrefix + filterConfig.name).pipe(
map((url: string) => { map((url: string) => {
const args: string[] = [`page=${valuePage - 1}`, `size=${filterConfig.pageSize}`]; const args: string[] = [`page=${valuePage - 1}`, `size=${filterConfig.pageSize}`];
if (hasValue(filterQuery)) {
// args.push(`${filterConfig.paramName}=${filterQuery},query`);
args.push(`prefix=${filterQuery}`);
}
if (hasValue(searchOptions)) { if (hasValue(searchOptions)) {
url = searchOptions.toRestUrl(url, args); url = searchOptions.toRestUrl(url, args);
} }
const request = new GetRequest(this.requestService.generateRequestId(), url); const request = new GetRequest(this.requestService.generateRequestId(), url);
return Object.assign(request, { return Object.assign(request, {
getResponseParser(): GenericConstructor<ResponseParsingService> { getResponseParser(): GenericConstructor<ResponseParsingService> {

View File

@@ -8,12 +8,14 @@ import { RemoteDataError } from '../../data/remote-data-error';
import { GetRequest } from '../../data/request.models'; import { GetRequest } from '../../data/request.models';
import { RequestEntry } from '../../data/request.reducer'; import { RequestEntry } from '../../data/request.reducer';
import { RequestService } from '../../data/request.service'; import { RequestService } from '../../data/request.service';
import { NormalizedObject } from '../models/normalized-object.model'; import { NormalizedObject } from '../models/normalized-object.model';
import { ObjectCacheService } from '../object-cache.service'; import { ObjectCacheService } from '../object-cache.service';
import { DSOSuccessResponse, ErrorResponse } from '../response-cache.models'; import { DSOSuccessResponse, ErrorResponse } from '../response-cache.models';
import { ResponseCacheEntry } from '../response-cache.reducer'; import { ResponseCacheEntry } from '../response-cache.reducer';
import { ResponseCacheService } from '../response-cache.service'; import { ResponseCacheService } from '../response-cache.service';
import { getMapsTo, getRelationMetadata, getRelationships } from './build-decorators'; import { getMapsTo, getRelationMetadata, getRelationships } from './build-decorators';
import { PageInfo } from '../../shared/page-info.model';
import { import {
getRequestFromSelflink, getRequestFromSelflink,
getResourceLinksFromResponse, getResourceLinksFromResponse,
@@ -96,7 +98,6 @@ export class RemoteDataBuildService {
error = new RemoteDataError(resEntry.response.statusCode, errorMessage); error = new RemoteDataError(resEntry.response.statusCode, errorMessage);
} }
} }
return new RemoteData( return new RemoteData(
requestPending, requestPending,
responsePending, responsePending,
@@ -107,7 +108,7 @@ export class RemoteDataBuildService {
}); });
} }
buildList<TNormalized extends NormalizedObject, TDomain>(href$: string | Observable<string>): Observable<RemoteData<TDomain[] | PaginatedList<TDomain>>> { buildList<TNormalized extends NormalizedObject, TDomain>(href$: string | Observable<string>): Observable<RemoteData<PaginatedList<TDomain>>> {
if (typeof href$ === 'string') { if (typeof href$ === 'string') {
href$ = Observable.of(href$); href$ = Observable.of(href$);
} }
@@ -144,11 +145,7 @@ export class RemoteDataBuildService {
); );
const payload$ = Observable.combineLatest(tDomainList$, pageInfo$, (tDomainList, pageInfo) => { const payload$ = Observable.combineLatest(tDomainList$, pageInfo$, (tDomainList, pageInfo) => {
if (hasValue(pageInfo)) {
return new PaginatedList(pageInfo, tDomainList); return new PaginatedList(pageInfo, tDomainList);
} else {
return tDomainList;
}
}); });
return this.toRemoteDataObservable(requestEntry$, responseCache$, payload$); return this.toRemoteDataObservable(requestEntry$, responseCache$, payload$);
@@ -160,35 +157,43 @@ export class RemoteDataBuildService {
const relationships = getRelationships(normalized.constructor) || []; const relationships = getRelationships(normalized.constructor) || [];
relationships.forEach((relationship: string) => { relationships.forEach((relationship: string) => {
let result;
if (hasValue(normalized[relationship])) { if (hasValue(normalized[relationship])) {
const { resourceType, isList } = getRelationMetadata(normalized, relationship); const { resourceType, isList } = getRelationMetadata(normalized, relationship);
if (Array.isArray(normalized[relationship])) { const objectList = normalized[relationship].page || normalized[relationship];
normalized[relationship].forEach((href: string) => { if (typeof objectList !== 'string') {
objectList.forEach((href: string) => {
this.requestService.configure(new GetRequest(this.requestService.generateRequestId(), href)) this.requestService.configure(new GetRequest(this.requestService.generateRequestId(), href))
}); });
const rdArr = []; const rdArr = [];
normalized[relationship].forEach((href: string) => { objectList.forEach((href: string) => {
rdArr.push(this.buildSingle(href)); rdArr.push(this.buildSingle(href));
}); });
if (isList) { if (isList) {
links[relationship] = this.aggregate(rdArr); result = this.aggregate(rdArr);
} else if (rdArr.length === 1) { } else if (rdArr.length === 1) {
links[relationship] = rdArr[0]; result = rdArr[0];
} }
} else { } else {
this.requestService.configure(new GetRequest(this.requestService.generateRequestId(), normalized[relationship])); this.requestService.configure(new GetRequest(this.requestService.generateRequestId(), objectList));
// The rest API can return a single URL to represent a list of resources (e.g. /items/:id/bitstreams) // The rest API can return a single URL to represent a list of resources (e.g. /items/:id/bitstreams)
// in that case only 1 href will be stored in the normalized obj (so the isArray above fails), // in that case only 1 href will be stored in the normalized obj (so the isArray above fails),
// but it should still be built as a list // but it should still be built as a list
if (isList) { if (isList) {
links[relationship] = this.buildList(normalized[relationship]); result = this.buildList(objectList);
} else { } else {
links[relationship] = this.buildSingle(normalized[relationship]); result = this.buildSingle(objectList);
} }
} }
if (hasValue(normalized[relationship].page)) {
links[relationship] = this.aggregatePaginatedList(result, normalized[relationship].pageInfo);
} else {
links[relationship] = result;
}
} }
}); });
@@ -249,4 +254,8 @@ export class RemoteDataBuildService {
}) })
} }
aggregatePaginatedList<T>(input: Observable<RemoteData<T[]>>, pageInfo: PageInfo): Observable<RemoteData<PaginatedList<T>>> {
return input.map((rd) => Object.assign(rd, {payload: new PaginatedList(pageInfo, rd.payload)}));
}
} }

View File

@@ -0,0 +1,34 @@
import { IDToUUIDSerializer } from './it-to-uuid-serializer';
describe('IDToUUIDSerializer', () => {
let serializer: IDToUUIDSerializer;
const prefix = 'test-prefix';
beforeEach(() => {
serializer = new IDToUUIDSerializer(prefix);
});
describe('Serialize', () => {
it('should return undefined', () => {
expect(serializer.Serialize('some-uuid')).toBeUndefined()
});
});
describe('Deserialize', () => {
describe('when ID is defined', () => {
it('should prepend the prefix to the ID', () => {
const id = 'some-id';
expect(serializer.Deserialize(id)).toBe(`${prefix}-${id}`);
});
});
describe('when ID is null or undefined', () => {
it('should return null or undefined', () => {
expect(serializer.Deserialize(null)).toBeNull();
expect(serializer.Deserialize(undefined)).toBeUndefined();
});
});
});
});

View File

@@ -0,0 +1,19 @@
import { hasValue } from '../../shared/empty.util';
export class IDToUUIDSerializer {
constructor(private prefix: string) {
}
Serialize(uuid: string): any {
return undefined; // ui-only uuid doesn't need to be sent back to the server
}
Deserialize(id: string): string {
if (hasValue(id)) {
return `${this.prefix}-${id}`;
} else {
return id;
}
}
}

View File

@@ -0,0 +1,35 @@
import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize';
import { BitstreamFormat } from '../../shared/bitstream-format.model';
import { mapsTo } from '../builders/build-decorators';
import { IDToUUIDSerializer } from '../it-to-uuid-serializer';
import { NormalizedObject } from './normalized-object.model';
@mapsTo(BitstreamFormat)
@inheritSerialization(NormalizedObject)
export class NormalizedBitstreamFormat extends NormalizedObject {
@autoserialize
shortDescription: string;
@autoserialize
description: string;
@autoserialize
mimetype: string;
@autoserialize
supportLevel: number;
@autoserialize
internal: boolean;
@autoserialize
extensions: string;
@autoserialize
id: string;
@autoserializeAs(new IDToUUIDSerializer('bitstream-format'), 'id')
uuid: string;
}

View File

@@ -1,4 +1,4 @@
import { autoserialize, inheritSerialization, autoserializeAs } from 'cerialize'; import { autoserialize, inheritSerialization } from 'cerialize';
import { NormalizedDSpaceObject } from './normalized-dspace-object.model'; import { NormalizedDSpaceObject } from './normalized-dspace-object.model';
import { Collection } from '../../shared/collection.model'; import { Collection } from '../../shared/collection.model';

View File

@@ -49,6 +49,7 @@ export class NormalizedItem extends NormalizedDSpaceObject {
/** /**
* The Collection that owns this Item * The Collection that owns this Item
*/ */
@autoserialize
@relationship(ResourceType.Collection, false) @relationship(ResourceType.Collection, false)
owningCollection: string; owningCollection: string;

View File

@@ -6,6 +6,8 @@ import { GenericConstructor } from '../../shared/generic-constructor';
import { NormalizedCommunity } from './normalized-community.model'; import { NormalizedCommunity } from './normalized-community.model';
import { ResourceType } from '../../shared/resource-type'; import { ResourceType } from '../../shared/resource-type';
import { NormalizedObject } from './normalized-object.model'; import { NormalizedObject } from './normalized-object.model';
import { NormalizedBitstreamFormat } from './normalized-bitstream-format.model';
import { NormalizedResourcePolicy } from './normalized-resource-policy.model';
export class NormalizedObjectFactory { export class NormalizedObjectFactory {
public static getConstructor(type: ResourceType): GenericConstructor<NormalizedObject> { public static getConstructor(type: ResourceType): GenericConstructor<NormalizedObject> {
@@ -25,6 +27,12 @@ export class NormalizedObjectFactory {
case ResourceType.Community: { case ResourceType.Community: {
return NormalizedCommunity return NormalizedCommunity
} }
case ResourceType.BitstreamFormat: {
return NormalizedBitstreamFormat
}
case ResourceType.ResourcePolicy: {
return NormalizedResourcePolicy
}
default: { default: {
return undefined; return undefined;
} }

View File

@@ -0,0 +1,23 @@
import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize';
import { ResourcePolicy } from '../../shared/resource-policy.model';
import { mapsTo } from '../builders/build-decorators';
import { IDToUUIDSerializer } from '../it-to-uuid-serializer';
import { NormalizedObject } from './normalized-object.model';
@mapsTo(ResourcePolicy)
@inheritSerialization(NormalizedObject)
export class NormalizedResourcePolicy extends NormalizedObject {
@autoserialize
name: string;
@autoserializeAs(String, 'groupUUID')
group: string;
@autoserialize
id: string;
@autoserializeAs(new IDToUUIDSerializer('resource-policy'), 'id')
uuid: string;
}

View File

@@ -46,7 +46,6 @@ describe('ResponseCacheService', () => {
let testObj: ResponseCacheEntry; let testObj: ResponseCacheEntry;
service.get(keys[1]).first().subscribe((entry) => { service.get(keys[1]).first().subscribe((entry) => {
console.log(entry);
testObj = entry; testObj = entry;
}); });
expect(testObj.key).toEqual(keys[1]); expect(testObj.key).toEqual(keys[1]);

View File

@@ -4,8 +4,9 @@ import { CacheableObject } from '../cache/object-cache.reducer';
import { PageInfo } from '../shared/page-info.model'; import { PageInfo } from '../shared/page-info.model';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { GlobalConfig } from '../../../config/global-config.interface'; import { GlobalConfig } from '../../../config/global-config.interface';
import { NormalizedObject } from '../cache/models/normalized-object.model';
import { GenericConstructor } from '../shared/generic-constructor'; import { GenericConstructor } from '../shared/generic-constructor';
import { PaginatedList } from './paginated-list';
import { NormalizedObject } from '../cache/models/normalized-object.model';
function isObjectLevel(halObj: any) { function isObjectLevel(halObj: any) {
return isNotEmpty(halObj._links) && hasValue(halObj._links.self); return isNotEmpty(halObj._links) && hasValue(halObj._links.self);
@@ -17,96 +18,103 @@ function isPaginatedResponse(halObj: any) {
/* tslint:disable:max-classes-per-file */ /* tslint:disable:max-classes-per-file */
class ProcessRequestDTO<ObjectDomain> {
[key: string]: ObjectDomain[]
}
export abstract class BaseResponseParsingService { export abstract class BaseResponseParsingService {
protected abstract EnvConfig: GlobalConfig; protected abstract EnvConfig: GlobalConfig;
protected abstract objectCache: ObjectCacheService; protected abstract objectCache: ObjectCacheService;
protected abstract objectFactory: any; protected abstract objectFactory: any;
protected abstract toCache: boolean; protected abstract toCache: boolean;
protected process<ObjectDomain,ObjectType>(data: any, requestHref: string): ProcessRequestDTO<ObjectDomain> { protected process<ObjectDomain, ObjectType>(data: any, requestHref: string): any {
if (isNotEmpty(data)) { if (isNotEmpty(data)) {
if (isPaginatedResponse(data)) { if (hasNoValue(data) || (typeof data !== 'object')) {
return this.process(data._embedded, requestHref); return data;
} else if (isPaginatedResponse(data)) {
return this.processPaginatedList(data, requestHref);
} else if (Array.isArray(data)) {
return this.processArray(data, requestHref);
} else if (isObjectLevel(data)) { } else if (isObjectLevel(data)) {
return { topLevel: this.deserializeAndCache(data, requestHref) }; const object = this.deserialize(data);
} else { if (isNotEmpty(data._embedded)) {
const result = new ProcessRequestDTO<ObjectDomain>(); Object
if (Array.isArray(data)) { .keys(data._embedded)
result.topLevel = []; .filter((property) => data._embedded.hasOwnProperty(property))
data.forEach((datum) => { .forEach((property) => {
if (isPaginatedResponse(datum)) { const parsedObj = this.process<ObjectDomain, ObjectType>(data._embedded[property], requestHref);
const obj = this.process(datum, requestHref); if (isNotEmpty(parsedObj)) {
result.topLevel = [...result.topLevel, ...this.flattenSingleKeyObject(obj)]; if (isPaginatedResponse(data._embedded[property])) {
} else { object[property] = parsedObj;
result.topLevel = [...result.topLevel, ...this.deserializeAndCache<ObjectDomain,ObjectType>(datum, requestHref)]; object[property].page = parsedObj.page.map((obj) => obj.self);
} else if (isObjectLevel(data._embedded[property])) {
object[property] = parsedObj.self;
} else if (Array.isArray(parsedObj)) {
object[property] = parsedObj.map((obj) => obj.self)
}
} }
}); });
} else { }
this.cache(object, requestHref);
return object;
}
const result = {};
Object.keys(data) Object.keys(data)
.filter((property) => data.hasOwnProperty(property)) .filter((property) => data.hasOwnProperty(property))
.filter((property) => hasValue(data[property])) .filter((property) => hasValue(data[property]))
.forEach((property) => { .forEach((property) => {
if (isPaginatedResponse(data[property])) {
const obj = this.process(data[property], requestHref); const obj = this.process(data[property], requestHref);
result[property] = this.flattenSingleKeyObject(obj); result[property] = obj;
} else {
result[property] = this.deserializeAndCache(data[property], requestHref);
}
}); });
}
return result; return result;
}
} }
} }
protected deserializeAndCache<ObjectDomain,ObjectType>(obj, requestHref: string): ObjectDomain[] { protected processPaginatedList<ObjectDomain, ObjectType>(data: any, requestHref: string): PaginatedList<ObjectDomain> {
if (Array.isArray(obj)) { const pageInfo: PageInfo = this.processPageInfo(data);
let result = []; let list = data._embedded;
obj.forEach((o) => result = [...result, ...this.deserializeAndCache<ObjectDomain,ObjectType>(o, requestHref)]);
return result; // Workaround for inconsistency in rest response. Issue: https://github.com/DSpace/dspace-angular/issues/238
if (!Array.isArray(list)) {
list = this.flattenSingleKeyObject(list);
}
const page: ObjectDomain[] = this.processArray(list, requestHref);
return new PaginatedList<ObjectDomain>(pageInfo, page);
} }
protected processArray<ObjectDomain, ObjectType>(data: any, requestHref: string): ObjectDomain[] {
let array: ObjectDomain[] = [];
data.forEach((datum) => {
array = [...array, this.process(datum, requestHref)];
}
);
return array;
}
protected deserialize<ObjectDomain, ObjectType>(obj): any {
const type: ObjectType = obj.type; const type: ObjectType = obj.type;
if (hasValue(type)) { if (hasValue(type)) {
const normObjConstructor = this.objectFactory.getConstructor(type) as GenericConstructor<ObjectDomain>; const normObjConstructor = this.objectFactory.getConstructor(type) as GenericConstructor<ObjectDomain>;
if (hasValue(normObjConstructor)) { if (hasValue(normObjConstructor)) {
const serializer = new DSpaceRESTv2Serializer(normObjConstructor); const serializer = new DSpaceRESTv2Serializer(normObjConstructor);
const res = serializer.deserialize(obj);
let processed; return res;
if (isNotEmpty(obj._embedded)) {
processed = this.process<ObjectDomain,ObjectType>(obj._embedded, requestHref);
}
const normalizedObj: any = serializer.deserialize(obj);
if (isNotEmpty(processed)) {
const processedList = {};
Object.keys(processed).forEach((key) => {
processedList[key] = processed[key].map((no: NormalizedObject) => (this.toCache) ? no.self : no);
});
Object.assign(normalizedObj, processedList);
}
if (this.toCache) {
this.addToObjectCache(normalizedObj, requestHref);
}
return [normalizedObj] as any;
} else { } else {
// TODO: move check to Validator? // TODO: move check to Validator?
// throw new Error(`The server returned an object with an unknown a known type: ${type}`); // throw new Error(`The server returned an object with an unknown a known type: ${type}`);
return []; return null;
} }
} else { } else {
// TODO: move check to Validator // TODO: move check to Validator
// throw new Error(`The server returned an object without a type: ${JSON.stringify(obj)}`); // throw new Error(`The server returned an object without a type: ${JSON.stringify(obj)}`);
return []; return null;
}
}
protected cache<ObjectDomain, ObjectType>(obj, requestHref) {
if (this.toCache) {
this.addToObjectCache(obj, requestHref);
} }
} }

View File

@@ -1,5 +1,4 @@
import { ConfigSuccessResponse, ErrorResponse } from '../cache/response-cache.models'; import { ConfigSuccessResponse, ErrorResponse } from '../cache/response-cache.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { ConfigResponseParsingService } from './config-response-parsing.service'; import { ConfigResponseParsingService } from './config-response-parsing.service';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { GlobalConfig } from '../../../config/global-config.interface'; import { GlobalConfig } from '../../../config/global-config.interface';
@@ -8,7 +7,8 @@ import { ConfigRequest } from './request.models';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { CoreState } from '../core.reducers'; import { CoreState } from '../core.reducers';
import { SubmissionDefinitionsModel } from '../shared/config/config-submission-definitions.model'; import { SubmissionDefinitionsModel } from '../shared/config/config-submission-definitions.model';
import { SubmissionSectionModel } from '../shared/config/config-submission-section.model'; import { PaginatedList } from './paginated-list';
import { PageInfo } from '../shared/page-info.model';
describe('ConfigResponseParsingService', () => { describe('ConfigResponseParsingService', () => {
let service: ConfigResponseParsingService; let service: ConfigResponseParsingService;
@@ -16,15 +16,10 @@ describe('ConfigResponseParsingService', () => {
const EnvConfig = {} as GlobalConfig; const EnvConfig = {} as GlobalConfig;
const store = {} as Store<CoreState>; const store = {} as Store<CoreState>;
const objectCacheService = new ObjectCacheService(store); const objectCacheService = new ObjectCacheService(store);
let validResponse;
beforeEach(() => { beforeEach(() => {
service = new ConfigResponseParsingService(EnvConfig, objectCacheService); service = new ConfigResponseParsingService(EnvConfig, objectCacheService);
}); validResponse = {
describe('parse', () => {
const validRequest = new ConfigRequest('69f375b5-19f4-4453-8c7a-7dc5c55aafbb', 'https://rest.api/config/submissiondefinitions/traditional');
const validResponse = {
payload: { payload: {
id: 'traditional', id: 'traditional',
name: 'traditional', name: 'traditional',
@@ -33,7 +28,8 @@ describe('ConfigResponseParsingService', () => {
_links: { _links: {
sections: { sections: {
href: 'https://rest.api/config/submissiondefinitions/traditional/sections' href: 'https://rest.api/config/submissiondefinitions/traditional/sections'
},self:{ },
self: {
href: 'https://rest.api/config/submissiondefinitions/traditional' href: 'https://rest.api/config/submissiondefinitions/traditional'
} }
}, },
@@ -116,13 +112,19 @@ describe('ConfigResponseParsingService', () => {
} }
], ],
_links: { _links: {
self:'https://rest.api/config/submissiondefinitions/traditional/sections' self: {
href: 'https://rest.api/config/submissiondefinitions/traditional/sections'
}
} }
} }
} }
}, },
statusCode: '200' statusCode: '200'
}; };
});
describe('parse', () => {
const validRequest = new ConfigRequest('69f375b5-19f4-4453-8c7a-7dc5c55aafbb', 'https://rest.api/config/submissiondefinitions/traditional');
const invalidResponse1 = { const invalidResponse1 = {
payload: {}, payload: {},
@@ -159,61 +161,24 @@ describe('ConfigResponseParsingService', () => {
page: { size: 20, totalElements: 2, totalPages: 1, number: 0 } page: { size: 20, totalElements: 2, totalPages: 1, number: 0 }
}, statusCode: '500' }, statusCode: '500'
}; };
const pageinfo = Object.assign(new PageInfo(), { elementsPerPage: 4, totalElements: 4, totalPages: 1, currentPage: 1 });
const definitions = [ const definitions =
Object.assign(new SubmissionDefinitionsModel(), { Object.assign(new SubmissionDefinitionsModel(), {
isDefault: true, isDefault: true,
name: 'traditional', name: 'traditional',
type: 'submissiondefinition', type: 'submissiondefinition',
_links: {}, _links: {
sections: [ sections: 'https://rest.api/config/submissiondefinitions/traditional/sections',
Object.assign(new SubmissionSectionModel(), { self: 'https://rest.api/config/submissiondefinitions/traditional'
header: 'submit.progressbar.describe.stepone',
mandatory: true,
sectionType: 'submission-form',
visibility:{
main:null,
other:'READONLY'
}, },
type: 'submissionsection', self: 'https://rest.api/config/submissiondefinitions/traditional',
_links: {} sections: new PaginatedList(pageinfo, [
}), 'https://rest.api/config/submissionsections/traditionalpageone',
Object.assign(new SubmissionSectionModel(), { 'https://rest.api/config/submissionsections/traditionalpagetwo',
header: 'submit.progressbar.describe.steptwo', 'https://rest.api/config/submissionsections/upload',
mandatory: true, 'https://rest.api/config/submissionsections/license'
sectionType: 'submission-form', ])
visibility:{ });
main:null,
other:'READONLY'
},
type: 'submissionsection',
_links: {}
}),
Object.assign(new SubmissionSectionModel(), {
header: 'submit.progressbar.upload',
mandatory: false,
sectionType: 'upload',
visibility:{
main:null,
other:'READONLY'
},
type: 'submissionsection',
_links: {}
}),
Object.assign(new SubmissionSectionModel(), {
header: 'submit.progressbar.license',
mandatory: true,
sectionType: 'license',
visibility:{
main:null,
other:'READONLY'
},
type: 'submissionsection',
_links: {}
})
]
})
];
it('should return a ConfigSuccessResponse if data contains a valid config endpoint response', () => { it('should return a ConfigSuccessResponse if data contains a valid config endpoint response', () => {
const response = service.parse(validRequest, validResponse); const response = service.parse(validRequest, validResponse);

View File

@@ -29,7 +29,7 @@ export class ConfigResponseParsingService extends BaseResponseParsingService imp
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && data.statusCode === '200') { if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && data.statusCode === '200') {
const configDefinition = this.process<ConfigObject,ConfigType>(data.payload, request.href); const configDefinition = this.process<ConfigObject,ConfigType>(data.payload, request.href);
return new ConfigSuccessResponse(configDefinition[Object.keys(configDefinition)[0]], data.statusCode, this.processPageInfo(data.payload)); return new ConfigSuccessResponse(configDefinition, data.statusCode, this.processPageInfo(data.payload));
} else { } else {
return new ErrorResponse( return new ErrorResponse(
Object.assign( Object.assign(

View File

@@ -12,6 +12,7 @@ import { RestRequest } from './request.models';
import { ResponseParsingService } from './parsing.service'; import { ResponseParsingService } from './parsing.service';
import { BaseResponseParsingService } from './base-response-parsing.service'; import { BaseResponseParsingService } from './base-response-parsing.service';
import { hasNoValue, hasValue } from '../../shared/empty.util';
@Injectable() @Injectable()
export class DSOResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { export class DSOResponseParsingService extends BaseResponseParsingService implements ResponseParsingService {
@@ -27,7 +28,16 @@ export class DSOResponseParsingService extends BaseResponseParsingService implem
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
const processRequestDTO = this.process<NormalizedObject,ResourceType>(data.payload, request.href); const processRequestDTO = this.process<NormalizedObject,ResourceType>(data.payload, request.href);
const selfLinks = this.flattenSingleKeyObject(processRequestDTO).map((no) => no.self); let objectList = processRequestDTO;
if (hasNoValue(processRequestDTO)) {
return new DSOSuccessResponse([], data.statusCode, undefined)
}
if (hasValue(processRequestDTO.page)) {
objectList = processRequestDTO.page;
} else if (!Array.isArray(processRequestDTO)) {
objectList = [processRequestDTO];
}
const selfLinks = objectList.map((no) => no.self);
return new DSOSuccessResponse(selfLinks, data.statusCode, this.processPageInfo(data.payload)) return new DSOSuccessResponse(selfLinks, data.statusCode, this.processPageInfo(data.payload))
} }

View File

@@ -5,8 +5,7 @@ import { ResponseParsingService } from './parsing.service';
import { RestRequest } from './request.models'; import { RestRequest } from './request.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer';
import { PageInfo } from '../shared/page-info.model'; import { hasValue } from '../../shared/empty.util';
import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { SearchQueryResponse } from '../../+search-page/search-service/search-query-response.model'; import { SearchQueryResponse } from '../../+search-page/search-service/search-query-response.model';
import { Metadatum } from '../shared/metadatum.model'; import { Metadatum } from '../shared/metadatum.model';
@@ -16,7 +15,7 @@ export class SearchResponseParsingService implements ResponseParsingService {
} }
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
const payload = data.payload; const payload = data.payload._embedded.searchResult;
const hitHighlights = payload._embedded.objects const hitHighlights = payload._embedded.objects
.map((object) => object.hitHighlights) .map((object) => object.hitHighlights)
.map((hhObject) => { .map((hhObject) => {
@@ -56,6 +55,6 @@ export class SearchResponseParsingService implements ResponseParsingService {
})); }));
payload.objects = objects; payload.objects = objects;
const deserialized = new DSpaceRESTv2Serializer(SearchQueryResponse).deserialize(payload); const deserialized = new DSpaceRESTv2Serializer(SearchQueryResponse).deserialize(payload);
return new SearchSuccessResponse(deserialized, data.statusCode, this.dsoParser.processPageInfo(data.payload)); return new SearchSuccessResponse(deserialized, data.statusCode, this.dsoParser.processPageInfo(payload));
} }
} }

View File

@@ -1,24 +1,25 @@
import { autoserialize } from 'cerialize'; import { CacheableObject } from '../cache/object-cache.reducer';
import { ResourceType } from './resource-type';
export class BitstreamFormat { export class BitstreamFormat implements CacheableObject {
@autoserialize
shortDescription: string; shortDescription: string;
@autoserialize
description: string; description: string;
@autoserialize
mimetype: string; mimetype: string;
@autoserialize
supportLevel: number; supportLevel: number;
@autoserialize
internal: boolean; internal: boolean;
@autoserialize
extensions: string; extensions: string;
self: string;
type: ResourceType;
uuid: string;
} }

View File

@@ -3,6 +3,7 @@ import { Bitstream } from './bitstream.model';
import { Collection } from './collection.model'; import { Collection } from './collection.model';
import { RemoteData } from '../data/remote-data'; import { RemoteData } from '../data/remote-data';
import { Observable } from 'rxjs/Observable'; import { Observable } from 'rxjs/Observable';
import { PaginatedList } from '../data/paginated-list';
export class Community extends DSpaceObject { export class Community extends DSpaceObject {
@@ -58,6 +59,6 @@ export class Community extends DSpaceObject {
*/ */
owner: Observable<RemoteData<Community>>; owner: Observable<RemoteData<Community>>;
collections: Observable<RemoteData<Collection[]>>; collections: Observable<RemoteData<PaginatedList<Collection>>>;
} }

View File

@@ -1,6 +1,7 @@
import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize';
import { ConfigObject } from './config.model'; import { ConfigObject } from './config.model';
import { SubmissionSectionModel } from './config-submission-section.model'; import { SubmissionSectionModel } from './config-submission-section.model';
import { PaginatedList } from '../../data/paginated-list';
@inheritSerialization(ConfigObject) @inheritSerialization(ConfigObject)
export class SubmissionDefinitionsModel extends ConfigObject { export class SubmissionDefinitionsModel extends ConfigObject {
@@ -9,6 +10,6 @@ export class SubmissionDefinitionsModel extends ConfigObject {
isDefault: boolean; isDefault: boolean;
@autoserializeAs(SubmissionSectionModel) @autoserializeAs(SubmissionSectionModel)
sections: SubmissionSectionModel[]; sections: PaginatedList<SubmissionSectionModel>;
} }

View File

@@ -5,6 +5,7 @@ import { Collection } from './collection.model';
import { RemoteData } from '../data/remote-data'; import { RemoteData } from '../data/remote-data';
import { Bitstream } from './bitstream.model'; import { Bitstream } from './bitstream.model';
import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { PaginatedList } from '../data/paginated-list';
export class Item extends DSpaceObject { export class Item extends DSpaceObject {
@@ -47,7 +48,7 @@ export class Item extends DSpaceObject {
return this.owningCollection; return this.owningCollection;
} }
bitstreams: Observable<RemoteData<Bitstream[]>>; bitstreams: Observable<RemoteData<PaginatedList<Bitstream>>>;
/** /**
* Retrieves the thumbnail of this item * Retrieves the thumbnail of this item
@@ -88,7 +89,7 @@ export class Item extends DSpaceObject {
*/ */
getBitstreamsByBundleName(bundleName: string): Observable<Bitstream[]> { getBitstreamsByBundleName(bundleName: string): Observable<Bitstream[]> {
return this.bitstreams return this.bitstreams
.map((rd: RemoteData<Bitstream[]>) => rd.payload) .map((rd: RemoteData<PaginatedList<Bitstream>>) => rd.payload.page)
.filter((bitstreams: Bitstream[]) => hasValue(bitstreams)) .filter((bitstreams: Bitstream[]) => hasValue(bitstreams))
.startWith([]) .startWith([])
.map((bitstreams) => { .map((bitstreams) => {

View File

@@ -0,0 +1,20 @@
import { CacheableObject } from '../cache/object-cache.reducer';
import { ResourceType } from './resource-type';
export class ResourcePolicy implements CacheableObject {
action: string;
name: string;
// TODO group should ofcourse become a group object
group: string;
self: string;
type: ResourceType;
uuid: string;
}

View File

@@ -8,4 +8,5 @@ export enum ResourceType {
Community = 'community', Community = 'community',
Eperson = 'eperson', Eperson = 'eperson',
Group = 'group', Group = 'group',
ResourcePolicy = 'resourcePolicy'
} }

View File

@@ -0,0 +1,21 @@
<form #form="ngForm" (ngSubmit)="submitSuggestion.emit(ngModel)"
[action]="action" (keydown)="onKeydown($event)"
(keydown.arrowdown)="shiftFocusDown($event)"
(keydown.arrowup)="shiftFocusUp($event)" (keydown.esc)="close()"
(dsClickOutside)="close()">
<input #inputField type="text" [(ngModel)]="ngModel" [name]="name"
class="form-control suggestion_input"
[dsDebounce]="debounceTime" (onDebounce)="findSuggestions.emit($event)"
[placeholder]="placeholder"
[ngModelOptions]="{standalone: true}" autocomplete="off"/>
<input type="submit" class="d-none"/>
<div class="autocomplete dropdown-menu" [ngClass]="{'show': (show | async) && isNotEmpty(suggestions)}">
<ul class="list-unstyled">
<li *ngFor="let suggestionOption of suggestions">
<a href="#" class="d-block dropdown-item" (click)="onClickSuggestion(suggestionOption.value)" #suggestion>
<span [innerHTML]="suggestionOption.displayValue"></span>
</a>
</li>
</ul>
</div>
</form>

View File

@@ -0,0 +1,16 @@
.autocomplete {
width: 100%;
.dropdown-item {
white-space: normal;
word-break: break-word;
&:focus {
outline: none;
}
}
}
form {
> div {
position: relative;
}
}

View File

@@ -0,0 +1,306 @@
import { ChangeDetectionStrategy, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import { InputSuggestionsComponent } from './input-suggestions.component';
import { By } from '@angular/platform-browser';
import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { RouterTestingModule } from '@angular/router/testing';
describe('InputSuggestionsComponent', () => {
let comp: InputSuggestionsComponent;
let fixture: ComponentFixture<InputSuggestionsComponent>;
let de: DebugElement;
let el: HTMLElement;
const suggestions = [{displayValue: 'suggestion uno', value: 'suggestion uno'}, {displayValue: 'suggestion dos', value: 'suggestion dos'}, {displayValue: 'suggestion tres', value: 'suggestion tres'}];
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule, FormsModule],
declarations: [InputSuggestionsComponent],
providers: [],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(InputSuggestionsComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default }
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(InputSuggestionsComponent);
comp = fixture.componentInstance; // LoadingComponent test instance
comp.suggestions = suggestions;
// query for the message <label> by CSS element selector
de = fixture.debugElement;
el = de.nativeElement;
comp.show.next(true);
fixture.detectChanges();
});
it('should create', () => {
expect(comp).toBeTruthy();
});
describe('when the input field is in focus', () => {
beforeEach(() => {
const inputElement = de.query(By.css('.suggestion_input'));
inputElement.nativeElement.focus();
fixture.detectChanges();
});
it('should not have any element in focus', () => {
const activeElement = el.ownerDocument.activeElement;
expect(activeElement.nodeName.toLowerCase()).not.toEqual('a');
});
describe('when key up is pressed', () => {
beforeEach(() => {
spyOn(comp, 'shiftFocusUp');
const form = de.query(By.css('form'));
form.triggerEventHandler('keydown.arrowup', {});
fixture.detectChanges();
});
it('should call shiftFocusUp()', () => {
expect(comp.shiftFocusUp).toHaveBeenCalled();
});
});
describe('when shiftFocusUp() is triggered', () => {
beforeEach(() => {
comp.shiftFocusUp(new KeyboardEvent('keydown.arrowup'));
fixture.detectChanges();
});
it('should put the focus on the last element ', () => {
const lastLink = de.query(By.css('.list-unstyled > li:last-child a'));
const activeElement = el.ownerDocument.activeElement;
expect(activeElement).toEqual(lastLink.nativeElement);
});
});
describe('when key down is pressed', () => {
beforeEach(() => {
spyOn(comp, 'shiftFocusDown');
const form = de.query(By.css('form'));
form.triggerEventHandler('keydown.arrowdown', {});
fixture.detectChanges();
});
it('should call shiftFocusDown()', () => {
expect(comp.shiftFocusDown).toHaveBeenCalled();
});
});
describe('when shiftFocusDown() is triggered', () => {
beforeEach(() => {
comp.shiftFocusDown(new KeyboardEvent('keydown.arrowdown'));
fixture.detectChanges();
});
it('should put the focus on the first element ', () => {
const firstLink = de.query(By.css('.list-unstyled > li:first-child a'));
const activeElement = el.ownerDocument.activeElement;
expect(activeElement).toEqual(firstLink.nativeElement);
});
});
describe('when changeFocus() is triggered when selectedIndex is 1', () => {
beforeEach(() => {
comp.selectedIndex = 1;
comp.changeFocus();
fixture.detectChanges();
});
it('should put the focus on the second element', () => {
const secondLink = de.query(By.css('.list-unstyled > li:nth-child(2) a'));
const activeElement = el.ownerDocument.activeElement;
expect(activeElement).toEqual(secondLink.nativeElement);
});
});
});
describe('when the first element is in focus', () => {
beforeEach(() => {
const firstLink = de.query(By.css('.list-unstyled > li:first-child a'));
firstLink.nativeElement.focus();
comp.selectedIndex = 0;
fixture.detectChanges();
});
describe('when shiftFocusUp() is triggered', () => {
beforeEach(() => {
comp.shiftFocusUp(new KeyboardEvent('keydown.arrowup'));
fixture.detectChanges();
});
it('should put the focus on the last element ', () => {
const lastLink = de.query(By.css('.list-unstyled > li:last-child a'));
const activeElement = el.ownerDocument.activeElement;
expect(activeElement).toEqual(lastLink.nativeElement);
});
});
describe('when shiftFocusDown() is triggered', () => {
beforeEach(() => {
comp.shiftFocusDown(new KeyboardEvent('keydown.arrowdown'));
fixture.detectChanges();
});
it('should put the focus on the second element ', () => {
const secondLink = de.query(By.css('.list-unstyled > li:nth-child(2) a'));
const activeElement = el.ownerDocument.activeElement;
expect(activeElement).toEqual(secondLink.nativeElement);
});
});
});
describe('when the last element is in focus', () => {
beforeEach(() => {
const lastLink = de.query(By.css('.list-unstyled > li:last-child a'));
lastLink.nativeElement.focus();
comp.selectedIndex = suggestions.length - 1;
fixture.detectChanges();
});
describe('when shiftFocusUp() is triggered', () => {
beforeEach(() => {
comp.shiftFocusUp(new KeyboardEvent('keydown.arrowup'));
fixture.detectChanges();
});
it('should put the focus on the second last element ', () => {
const secondLastLink = de.query(By.css('.list-unstyled > li:nth-last-child(2) a'));
const activeElement = el.ownerDocument.activeElement;
expect(activeElement).toEqual(secondLastLink.nativeElement);
});
});
describe('when shiftFocusDown() is triggered', () => {
beforeEach(() => {
comp.shiftFocusDown(new KeyboardEvent('keydown.arrowdown'));
fixture.detectChanges();
});
it('should put the focus on the first element ', () => {
const firstLink = de.query(By.css('.list-unstyled > li:first-child a'));
const activeElement = el.ownerDocument.activeElement;
expect(activeElement).toEqual(firstLink.nativeElement);
});
});
describe('when any key but is pressed in the form', () => {
beforeEach(() => {
spyOn(comp, 'onKeydown');
const form = de.query(By.css('form'));
form.triggerEventHandler('keydown', { key: 'Shift' });
fixture.detectChanges();
});
it('should call onKeydown', () => {
expect(comp.onKeydown).toHaveBeenCalled();
fixture.detectChanges();
})
});
describe('when onKeydown is triggered with the Enter key', () => {
beforeEach(() => {
spyOn(comp.queryInput.nativeElement, 'focus');
comp.onKeydown(new KeyboardEvent('keydown', {key: 'Enter'}));
fixture.detectChanges();
});
it('should not change the focus', () => {
expect(comp.queryInput.nativeElement.focus).not.toHaveBeenCalled();
});
});
describe('when onKeydown is triggered with the any other (not-Enter) key', () => {
beforeEach(() => {
spyOn(comp.queryInput.nativeElement, 'focus');
comp.onKeydown(new KeyboardEvent('keydown', {key: 'Shift'}));
fixture.detectChanges();
});
it('should change the focus', () => {
expect(comp.queryInput.nativeElement.focus).toHaveBeenCalled();
});
});
});
describe('when the suggestions list is not empty and show is true', () => {
beforeEach(() => {
comp.show.next(true);
fixture.detectChanges();
});
it('should contain an .autocomplete list with a \'show\' class', () => {
const autocomplete = de.query(By.css('div.autocomplete'));
expect(autocomplete.nativeElement.classList).toContain('show');
});
});
describe('when the suggestions list is not empty and show is false', () => {
beforeEach(() => {
comp.show.next(false);
fixture.detectChanges();
});
it('should contain an .autocomplete list without a \'show\' class', () => {
const autocomplete = de.query(By.css('div.autocomplete'));
expect(autocomplete.nativeElement.classList).not.toContain('show');
});
});
describe('when the suggestions list is empty and show is false', () => {
beforeEach(() => {
comp.suggestions = [];
comp.show.next(false);
fixture.detectChanges();
});
it('should contain an .autocomplete list without a \'show\' class', () => {
const autocomplete = de.query(By.css('div.autocomplete'));
expect(autocomplete.nativeElement.classList).not.toContain('show');
});
});
describe('when the suggestions list is empty and show is true', () => {
beforeEach(() => {
comp.suggestions = [];
comp.show.next(true);
fixture.detectChanges();
});
it('should contain an .autocomplete list without a \'show\' class', () => {
const autocomplete = de.query(By.css('div.autocomplete'));
expect(autocomplete.nativeElement.classList).not.toContain('show');
});
});
describe('when the variable \'show\' is set to true and close() is called', () => {
beforeEach(() => {
comp.show.next(true);
comp.close();
fixture.detectChanges();
});
it('should set \'show\' to false', () => {
expect(comp.show.getValue()).toBeFalsy();
});
});
describe('when an element is clicked', () => {
const clickedIndex = 0;
beforeEach(() => {
spyOn(comp, 'onClickSuggestion');
const clickedLink = de.query(By.css('.list-unstyled > li:nth-child(' + (clickedIndex + 1) + ') a'));
clickedLink.triggerEventHandler('click', {} );
fixture.detectChanges();
});
it('should call onClickSuggestion() with the suggestion as a parameter', () => {
expect(comp.onClickSuggestion).toHaveBeenCalledWith(suggestions[clickedIndex].value);
});
});
});

View File

@@ -0,0 +1,97 @@
import {
Component,
ElementRef, EventEmitter,
Input,
Output,
QueryList, SimpleChanges,
ViewChild,
ViewChildren
} from '@angular/core';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import { hasValue, isNotEmpty } from '../empty.util';
import { ActivatedRoute } from '@angular/router';
/**
* This component renders a simple item page.
* The route parameter 'id' is used to request the item it represents.
* All fields of the item that should be displayed, are defined in its template.
*/
@Component({
selector: 'ds-input-suggestions',
styleUrls: ['./input-suggestions.component.scss'],
templateUrl: './input-suggestions.component.html'
})
export class InputSuggestionsComponent {
@Input() suggestions: any[] = [];
@Input() debounceTime = 500;
@Input() placeholder = '';
@Input() action;
@Input() name;
@Input() ngModel;
@Output() ngModelChange = new EventEmitter();
@Output() submitSuggestion = new EventEmitter();
@Output() clickSuggestion = new EventEmitter();
@Output() findSuggestions = new EventEmitter();
show = new BehaviorSubject<boolean>(false);
selectedIndex = -1;
@ViewChild('inputField') queryInput: ElementRef;
@ViewChildren('suggestion') resultViews: QueryList<ElementRef>;
ngOnChanges(changes: SimpleChanges) {
if (hasValue(changes.suggestions)) {
this.show.next(isNotEmpty(changes.suggestions.currentValue));
}
}
shiftFocusUp(event: KeyboardEvent) {
event.preventDefault();
if (this.selectedIndex > 0) {
this.selectedIndex--;
this.selectedIndex = (this.selectedIndex + this.resultViews.length) % this.resultViews.length; // Prevent negative modulo outcome
} else {
this.selectedIndex = this.resultViews.length - 1;
}
this.changeFocus();
}
shiftFocusDown(event: KeyboardEvent) {
event.preventDefault();
if (this.selectedIndex >= 0) {
this.selectedIndex++;
this.selectedIndex %= this.resultViews.length;
} else {
this.selectedIndex = 0;
}
this.changeFocus();
}
changeFocus() {
if (this.resultViews.length > 0) {
this.resultViews.toArray()[this.selectedIndex].nativeElement.focus();
}
}
onKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter') {
this.queryInput.nativeElement.focus();
}
}
close() {
this.show.next(false);
}
isNotEmpty(data) {
return isNotEmpty(data);
}
onClickSuggestion(data) {
this.clickSuggestion.emit(data);
this.close();
this.queryInput.nativeElement.focus();
return false;
}
}

View File

@@ -17,7 +17,14 @@ export const MockItem: Item = Object.assign(new Item(), {
errorMessage: '', errorMessage: '',
statusCode: '202', statusCode: '202',
pageInfo: {}, pageInfo: {},
payload: [ payload: {
pageInfo: {
elementsPerPage: 20,
totalElements: 3,
totalPages: 1,
currentPage: 2
},
page: [
{ {
sizeBytes: 10201, sizeBytes: 10201,
content: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', content: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content',
@@ -89,6 +96,7 @@ export const MockItem: Item = Object.assign(new Item(), {
] ]
} }
] ]
}
}), }),
self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357', self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357',
id: '0ec7ff22-f211-40ab-a69e-c819b0b1f357', id: '0ec7ff22-f211-40ab-a69e-c819b0b1f357',

View File

@@ -5,7 +5,7 @@
<span class="text-muted"> <span class="text-muted">
<ds-truncatable-part [id]="dso.id" [minLines]="1"> <ds-truncatable-part [id]="dso.id" [minLines]="1">
(<span *ngIf="dso.findMetadata('dc.publisher')" class="item-list-publisher" (<span *ngIf="dso.findMetadata('dc.publisher')" class="item-list-publisher"
[innerHTML]="getFirstValue('dc.publisher')">, </span><span [innerHTML]="getFirstValue('dc.publisher') + ', '"></span><span
*ngIf="dso.findMetadata('dc.date.issued')" class="item-list-date" *ngIf="dso.findMetadata('dc.date.issued')" class="item-list-date"
[innerHTML]="getFirstValue('dc.date.issued')"></span>) [innerHTML]="getFirstValue('dc.date.issued')"></span>)
<span *ngIf="dso.filterMetadata(['dc.contributor.author', 'dc.creator', 'dc.contributor.*']).length > 0" <span *ngIf="dso.filterMetadata(['dc.contributor.author', 'dc.creator', 'dc.contributor.*']).length > 0"

View File

@@ -43,14 +43,16 @@ import { VarDirective } from './utils/var.directive';
import { LogInComponent } from './log-in/log-in.component'; import { LogInComponent } from './log-in/log-in.component';
import { AuthNavMenuComponent } from './auth-nav-menu/auth-nav-menu.component'; import { AuthNavMenuComponent } from './auth-nav-menu/auth-nav-menu.component';
import { LogOutComponent } from './log-out/log-out.component'; import { LogOutComponent } from './log-out/log-out.component';
import { NotificationComponent } from './notifications/notification/notification.component';
import { NotificationsBoardComponent } from './notifications/notifications-board/notifications-board.component';
import { DragClickDirective } from './utils/drag-click.directive'; import { DragClickDirective } from './utils/drag-click.directive';
import { TruncatePipe } from './utils/truncate.pipe'; import { TruncatePipe } from './utils/truncate.pipe';
import { TruncatableComponent } from './truncatable/truncatable.component'; import { TruncatableComponent } from './truncatable/truncatable.component';
import { TruncatableService } from './truncatable/truncatable.service'; import { TruncatableService } from './truncatable/truncatable.service';
import { TruncatablePartComponent } from './truncatable/truncatable-part/truncatable-part.component'; import { TruncatablePartComponent } from './truncatable/truncatable-part/truncatable-part.component';
import { MockAdminGuard } from './mocks/mock-admin-guard.service'; import { MockAdminGuard } from './mocks/mock-admin-guard.service';
import { DebounceDirective } from './utils/debounce.directive';
import { ClickOutsideDirective } from './utils/click-outside.directive';
import { EmphasizePipe } from './utils/emphasize.pipe';
import { InputSuggestionsComponent } from './input-suggestions/input-suggestions.component';
const MODULES = [ const MODULES = [
// Do NOT include UniversalModule, HttpModule, or JsonpModule here // Do NOT include UniversalModule, HttpModule, or JsonpModule here
@@ -68,7 +70,8 @@ const PIPES = [
EnumKeysPipe, EnumKeysPipe,
FileSizePipe, FileSizePipe,
SafeUrlPipe, SafeUrlPipe,
TruncatePipe TruncatePipe,
EmphasizePipe
]; ];
const COMPONENTS = [ const COMPONENTS = [
@@ -95,6 +98,7 @@ const COMPONENTS = [
ViewModeSwitchComponent, ViewModeSwitchComponent,
TruncatableComponent, TruncatableComponent,
TruncatablePartComponent, TruncatablePartComponent,
InputSuggestionsComponent
]; ];
const ENTRY_COMPONENTS = [ const ENTRY_COMPONENTS = [
@@ -116,7 +120,9 @@ const PROVIDERS = [
const DIRECTIVES = [ const DIRECTIVES = [
VarDirective, VarDirective,
DragClickDirective DragClickDirective,
DebounceDirective,
ClickOutsideDirective
]; ];
@NgModule({ @NgModule({

View File

@@ -0,0 +1,20 @@
import {Directive, ElementRef, Output, EventEmitter, HostListener} from '@angular/core';
@Directive({
selector: '[dsClickOutside]'
})
export class ClickOutsideDirective {
@Output()
public dsClickOutside = new EventEmitter();
constructor(private _elementRef: ElementRef) {
}
@HostListener('document:click', ['$event.target'])
public onClick(targetElement) {
const clickedInside = this._elementRef.nativeElement.contains(targetElement);
if (!clickedInside) {
this.dsClickOutside.emit(null);
}
}
}

View File

@@ -0,0 +1,42 @@
import { Directive, Input, Output, EventEmitter, OnDestroy, OnInit } from '@angular/core';
import { NgControl } from '@angular/forms';
import 'rxjs/add/operator/debounceTime';
import 'rxjs/add/operator/distinctUntilChanged';
import 'rxjs/add/operator/takeUntil';
import { Subject } from 'rxjs/Subject';
@Directive({
selector: '[ngModel][dsDebounce]',
})
export class DebounceDirective implements OnInit, OnDestroy {
@Output()
public onDebounce = new EventEmitter<any>();
@Input()
public dsDebounce = 500;
private isFirstChange = true;
private ngUnsubscribe: Subject<void> = new Subject<void>();
constructor(public model: NgControl) {
}
ngOnInit() {
this.model.valueChanges
.takeUntil(this.ngUnsubscribe)
.debounceTime(this.dsDebounce)
.distinctUntilChanged()
.subscribe((modelValue) => {
if (this.isFirstChange) {
this.isFirstChange = false;
} else {
this.onDebounce.emit(modelValue);
}
});
}
ngOnDestroy() {
this.ngUnsubscribe.next();
this.ngUnsubscribe.complete();
}
}

View File

@@ -0,0 +1,36 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({ name: 'dsEmphasize' })
export class EmphasizePipe implements PipeTransform {
specials = [
// order matters for these
'-'
, '['
, ']'
// order doesn't matter for any of these
, '/'
, '{'
, '}'
, '('
, ')'
, '*'
, '+'
, '?'
, '.'
, '\\'
, '^'
, '$'
, '|'
];
regex = RegExp('[' + this.specials.join('\\') + ']', 'g');
transform(haystack, needle): any {
const escaped = this.escapeRegExp(needle);
const reg = new RegExp(escaped, 'gi');
return haystack.replace(reg, '<em>$&</em>');
}
escapeRegExp(str) {
return str.replace(this.regex, '\\$&');
}
}