Merge branch 'master' into w2p-60168_Alphabetic-browse-widget

Conflicts:
	src/app/core/browse/browse.service.spec.ts
	src/app/core/browse/browse.service.ts
	src/app/core/data/dso-response-parsing.service.ts
	src/app/shared/shared.module.ts
This commit is contained in:
Kristof De Langhe
2019-02-21 13:35:21 +01:00
194 changed files with 4525 additions and 1762 deletions

View File

@@ -18,9 +18,16 @@ module.exports = {
// Caching settings // Caching settings
cache: { cache: {
// NOTE: how long should objects be cached for by default // NOTE: how long should objects be cached for by default
msToLive: 15 * 60 * 1000, // 15 minutes msToLive: {
default: 15 * 60 * 1000, // 15 minutes
},
// msToLive: 1000, // 15 minutes // msToLive: 1000, // 15 minutes
control: 'max-age=60' // revalidate browser control: 'max-age=60', // revalidate browser
autoSync: {
defaultTime: 0,
maxBufferSize: 100,
timePerMethod: {'PATCH': 3} //time in seconds
}
}, },
// Form settings // Form settings
form: { form: {

View File

@@ -96,6 +96,7 @@
"core-js": "^2.5.7", "core-js": "^2.5.7",
"express": "4.16.2", "express": "4.16.2",
"express-session": "1.15.6", "express-session": "1.15.6",
"fast-json-patch": "^2.0.7",
"font-awesome": "4.7.0", "font-awesome": "4.7.0",
"fork-ts-checker-webpack-plugin": "^0.4.10", "fork-ts-checker-webpack-plugin": "^0.4.10",
"http-server": "0.11.1", "http-server": "0.11.1",

View File

@@ -13,6 +13,38 @@
"head": "Recent Submissions" "head": "Recent Submissions"
} }
} }
},
"form": {
"title": "Name",
"description": "Introductory text (HTML)",
"abstract": "Short Description",
"rights": "Copyright text (HTML)",
"tableofcontents": "News (HTML)",
"license": "License",
"provenance": "Provenance",
"errors": {
"title": {
"required": "Please enter a collection name"
}
}
},
"edit": {
"head": "Edit Collection",
"delete": "Delete this collection"
},
"create": {
"head": "Create a Collection",
"sub-head": "Create a Collection for Community {{ parent }}"
},
"delete": {
"head": "Delete Collection",
"text": "Are you sure you want to delete collection \"{{ dso }}\"",
"confirm": "Confirm",
"cancel": "Cancel",
"notification": {
"success": "Successfully deleted collection",
"fail": "Collection could not be deleted"
}
} }
}, },
"community": { "community": {
@@ -25,6 +57,36 @@
}, },
"sub-community-list": { "sub-community-list": {
"head": "Communities of this Community" "head": "Communities of this Community"
},
"form": {
"title": "Name",
"description": "Introductory text (HTML)",
"abstract": "Short Description",
"rights": "Copyright text (HTML)",
"tableofcontents": "News (HTML)",
"errors": {
"title": {
"required": "Please enter a community name"
}
}
},
"edit": {
"head": "Edit Community",
"delete": "Delete this community"
},
"create": {
"head": "Create a Community",
"sub-head": "Create a Sub-Community for Community {{ parent }}"
},
"delete": {
"head": "Delete Community",
"text": "Are you sure you want to delete community \"{{ dso }}\"",
"confirm": "Confirm",
"cancel": "Cancel",
"notification": {
"success": "Successfully deleted community",
"fail": "Community could not be deleted"
}
} }
}, },
"item": { "item": {

View File

@@ -0,0 +1,71 @@
import { Component, Input } from '@angular/core';
import {
DynamicInputModel,
DynamicTextAreaModel
} from '@ng-dynamic-forms/core';
import { DynamicFormControlModel } from '@ng-dynamic-forms/core/src/model/dynamic-form-control.model';
import { ResourceType } from '../../core/shared/resource-type';
import { Collection } from '../../core/shared/collection.model';
import { ComColFormComponent } from '../../shared/comcol-forms/comcol-form/comcol-form.component';
/**
* Form used for creating and editing collections
*/
@Component({
selector: 'ds-collection-form',
styleUrls: ['../../shared/comcol-forms/comcol-form/comcol-form.component.scss'],
templateUrl: '../../shared/comcol-forms/comcol-form/comcol-form.component.html'
})
export class CollectionFormComponent extends ComColFormComponent<Collection> {
/**
* @type {Collection} A new collection when a collection is being created, an existing Input collection when a collection is being edited
*/
@Input() dso: Collection = new Collection();
/**
* @type {ResourceType.Collection} This is a collection-type form
*/
protected type = ResourceType.Collection;
/**
* The dynamic form fields used for creating/editing a collection
* @type {(DynamicInputModel | DynamicTextAreaModel)[]}
*/
formModel: DynamicFormControlModel[] = [
new DynamicInputModel({
id: 'title',
name: 'dc.title',
required: true,
validators: {
required: null
},
errorMessages: {
required: 'Please enter a name for this title'
},
}),
new DynamicTextAreaModel({
id: 'description',
name: 'dc.description',
}),
new DynamicTextAreaModel({
id: 'abstract',
name: 'dc.description.abstract',
}),
new DynamicTextAreaModel({
id: 'rights',
name: 'dc.rights',
}),
new DynamicTextAreaModel({
id: 'tableofcontents',
name: 'dc.description.tableofcontents',
}),
new DynamicTextAreaModel({
id: 'license',
name: 'dc.rights.license',
}),
new DynamicTextAreaModel({
id: 'provenance',
name: 'dc.description.provenance',
}),
];
}

View File

@@ -3,10 +3,38 @@ import { RouterModule } from '@angular/router';
import { CollectionPageComponent } from './collection-page.component'; import { CollectionPageComponent } from './collection-page.component';
import { CollectionPageResolver } from './collection-page.resolver'; import { CollectionPageResolver } from './collection-page.resolver';
import { CreateCollectionPageComponent } from './create-collection-page/create-collection-page.component';
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
import { EditCollectionPageComponent } from './edit-collection-page/edit-collection-page.component';
import { CreateCollectionPageGuard } from './create-collection-page/create-collection-page.guard';
import { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component';
@NgModule({ @NgModule({
imports: [ imports: [
RouterModule.forChild([ RouterModule.forChild([
{
path: 'create',
component: CreateCollectionPageComponent,
canActivate: [AuthenticatedGuard, CreateCollectionPageGuard]
},
{
path: ':id/edit',
pathMatch: 'full',
component: EditCollectionPageComponent,
canActivate: [AuthenticatedGuard],
resolve: {
dso: CollectionPageResolver
}
},
{
path: ':id/delete',
pathMatch: 'full',
component: DeleteCollectionPageComponent,
canActivate: [AuthenticatedGuard],
resolve: {
dso: CollectionPageResolver
}
},
{ {
path: ':id', path: ':id',
component: CollectionPageComponent, component: CollectionPageComponent,
@@ -19,6 +47,7 @@ import { CollectionPageResolver } from './collection-page.resolver';
], ],
providers: [ providers: [
CollectionPageResolver, CollectionPageResolver,
CreateCollectionPageGuard
] ]
}) })
export class CollectionPageRoutingModule { export class CollectionPageRoutingModule {

View File

@@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { Observable, Subscription } from 'rxjs'; import { Observable, Subscription } from 'rxjs';
import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model';
import { CollectionDataService } from '../core/data/collection-data.service'; import { CollectionDataService } from '../core/data/collection-data.service';
import { PaginatedList } from '../core/data/paginated-list'; import { PaginatedList } from '../core/data/paginated-list';
@@ -15,7 +15,7 @@ import { Item } from '../core/shared/item.model';
import { fadeIn, fadeInOut } from '../shared/animations/fade'; import { fadeIn, fadeInOut } from '../shared/animations/fade';
import { hasValue, isNotEmpty } from '../shared/empty.util'; import { hasValue, isNotEmpty } from '../shared/empty.util';
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
import { filter, flatMap, map } from 'rxjs/operators'; import { filter, flatMap, map, tap } from 'rxjs/operators';
import { SearchService } from '../+search-page/search-service/search.service'; import { SearchService } from '../+search-page/search-service/search.service';
import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model'; import { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model';
import { toDSpaceObjectListRD } from '../core/shared/operators'; import { toDSpaceObjectListRD } from '../core/shared/operators';
@@ -55,7 +55,8 @@ export class CollectionPageComponent implements OnInit, OnDestroy {
ngOnInit(): void { ngOnInit(): void {
this.collectionRD$ = this.route.data.pipe( this.collectionRD$ = this.route.data.pipe(
map((data) => data.collection) map((data) => data.collection),
tap((data) => this.collectionId = data.payload.id)
); );
this.logoRD$ = this.collectionRD$.pipe( this.logoRD$ = this.collectionRD$.pipe(
map((rd: RemoteData<Collection>) => rd.payload), map((rd: RemoteData<Collection>) => rd.payload),
@@ -75,8 +76,8 @@ export class CollectionPageComponent implements OnInit, OnDestroy {
pagination: pagination, pagination: pagination,
sort: this.sortConfig sort: this.sortConfig
}); });
}) }));
);
} }
updatePage(searchOptions) { updatePage(searchOptions) {

View File

@@ -5,7 +5,11 @@ import { SharedModule } from '../shared/shared.module';
import { CollectionPageComponent } from './collection-page.component'; import { CollectionPageComponent } from './collection-page.component';
import { CollectionPageRoutingModule } from './collection-page-routing.module'; import { CollectionPageRoutingModule } from './collection-page-routing.module';
import { CreateCollectionPageComponent } from './create-collection-page/create-collection-page.component';
import { CollectionFormComponent } from './collection-form/collection-form.component';
import { SearchPageModule } from '../+search-page/search-page.module'; import { SearchPageModule } from '../+search-page/search-page.module';
import { EditCollectionPageComponent } from './edit-collection-page/edit-collection-page.component';
import { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -16,6 +20,10 @@ import { SearchPageModule } from '../+search-page/search-page.module';
], ],
declarations: [ declarations: [
CollectionPageComponent, CollectionPageComponent,
CreateCollectionPageComponent,
EditCollectionPageComponent,
DeleteCollectionPageComponent,
CollectionFormComponent
] ]
}) })
export class CollectionPageModule { export class CollectionPageModule {

View File

@@ -0,0 +1,8 @@
<div class="container">
<div class="row">
<div class="col-12 pb-4">
<h2 id="sub-header" class="border-bottom pb-2">{{'collection.create.sub-head' | translate:{ parent: (parentRD$| async)?.payload.name } }}</h2>
</div>
</div>
<ds-collection-form (submitForm)="onSubmit($event)"></ds-collection-form>
</div>

View File

@@ -0,0 +1,46 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { CommonModule } from '@angular/common';
import { RouterTestingModule } from '@angular/router/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { RouteService } from '../../shared/services/route.service';
import { SharedModule } from '../../shared/shared.module';
import { CollectionDataService } from '../../core/data/collection-data.service';
import { of as observableOf } from 'rxjs';
import { CommunityDataService } from '../../core/data/community-data.service';
import { CreateCollectionPageComponent } from './create-collection-page.component';
describe('CreateCollectionPageComponent', () => {
let comp: CreateCollectionPageComponent;
let fixture: ComponentFixture<CreateCollectionPageComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule],
declarations: [CreateCollectionPageComponent],
providers: [
{ provide: CollectionDataService, useValue: {} },
{
provide: CommunityDataService,
useValue: { findById: () => observableOf({ payload: { name: 'test' } }) }
},
{ provide: RouteService, useValue: { getQueryParameterValue: () => observableOf('1234') } },
{ provide: Router, useValue: {} },
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CreateCollectionPageComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
describe('frontendURL', () => {
it('should have the right frontendURL set', () => {
expect((comp as any).frontendURL).toEqual('/collections/');
})
});
});

View File

@@ -0,0 +1,29 @@
import { Component } from '@angular/core';
import { CommunityDataService } from '../../core/data/community-data.service';
import { RouteService } from '../../shared/services/route.service';
import { Router } from '@angular/router';
import { CreateComColPageComponent } from '../../shared/comcol-forms/create-comcol-page/create-comcol-page.component';
import { NormalizedCollection } from '../../core/cache/models/normalized-collection.model';
import { Collection } from '../../core/shared/collection.model';
import { CollectionDataService } from '../../core/data/collection-data.service';
/**
* Component that represents the page where a user can create a new Collection
*/
@Component({
selector: 'ds-create-collection',
styleUrls: ['./create-collection-page.component.scss'],
templateUrl: './create-collection-page.component.html'
})
export class CreateCollectionPageComponent extends CreateComColPageComponent<Collection, NormalizedCollection> {
protected frontendURL = '/collections/';
public constructor(
protected communityDataService: CommunityDataService,
protected collectionDataService: CollectionDataService,
protected routeService: RouteService,
protected router: Router
) {
super(collectionDataService, communityDataService, routeService, router);
}
}

View File

@@ -0,0 +1,67 @@
import { CreateCollectionPageGuard } from './create-collection-page.guard';
import { MockRouter } from '../../shared/mocks/mock-router';
import { RemoteData } from '../../core/data/remote-data';
import { Community } from '../../core/shared/community.model';
import { of as observableOf } from 'rxjs';
import { first } from 'rxjs/operators';
describe('CreateCollectionPageGuard', () => {
describe('canActivate', () => {
let guard: CreateCollectionPageGuard;
let router;
let communityDataServiceStub: any;
beforeEach(() => {
communityDataServiceStub = {
findById: (id: string) => {
if (id === 'valid-id') {
return observableOf(new RemoteData(false, false, true, null, new Community()));
} else if (id === 'invalid-id') {
return observableOf(new RemoteData(false, false, true, null, undefined));
} else if (id === 'error-id') {
return observableOf(new RemoteData(false, false, false, null, new Community()));
}
}
};
router = new MockRouter();
guard = new CreateCollectionPageGuard(router, communityDataServiceStub);
});
it('should return true when the parent ID resolves to a community', () => {
guard.canActivate({ queryParams: { parent: 'valid-id' } } as any, undefined)
.pipe(first())
.subscribe(
(canActivate) =>
expect(canActivate).toEqual(true)
);
});
it('should return false when no parent ID has been provided', () => {
guard.canActivate({ queryParams: { } } as any, undefined)
.pipe(first())
.subscribe(
(canActivate) =>
expect(canActivate).toEqual(false)
);
});
it('should return false when the parent ID does not resolve to a community', () => {
guard.canActivate({ queryParams: { parent: 'invalid-id' } } as any, undefined)
.pipe(first())
.subscribe(
(canActivate) =>
expect(canActivate).toEqual(false)
);
});
it('should return false when the parent ID resolves to an error response', () => {
guard.canActivate({ queryParams: { parent: 'error-id' } } as any, undefined)
.pipe(first())
.subscribe(
(canActivate) =>
expect(canActivate).toEqual(false)
);
});
});
});

View File

@@ -0,0 +1,46 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router';
import { hasNoValue, hasValue } from '../../shared/empty.util';
import { CommunityDataService } from '../../core/data/community-data.service';
import { RemoteData } from '../../core/data/remote-data';
import { Community } from '../../core/shared/community.model';
import { getFinishedRemoteData } from '../../core/shared/operators';
import { map, tap } from 'rxjs/operators';
import { Observable, of as observableOf } from 'rxjs';
/**
* Prevent creation of a collection without a parent community provided
* @class CreateCollectionPageGuard
*/
@Injectable()
export class CreateCollectionPageGuard implements CanActivate {
public constructor(private router: Router, private communityService: CommunityDataService) {
}
/**
* True when either a parent ID query parameter has been provided and the parent ID resolves to a valid parent community
* Reroutes to a 404 page when the page cannot be activated
* @method canActivate
*/
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
const parentID = route.queryParams.parent;
if (hasNoValue(parentID)) {
this.router.navigate(['/404']);
return observableOf(false);
}
const parent: Observable<RemoteData<Community>> = this.communityService.findById(parentID)
.pipe(
getFinishedRemoteData(),
);
return parent.pipe(
map((communityRD: RemoteData<Community>) => hasValue(communityRD) && communityRD.hasSucceeded && hasValue(communityRD.payload)),
tap((isValid: boolean) => {
if (!isValid) {
this.router.navigate(['/404']);
}
})
);
}
}

View File

@@ -0,0 +1,19 @@
<div class="container">
<div class="row">
<ng-container *ngVar="(dsoRD$ | async)?.payload as dso">
<div class="col-12 pb-4">
<h2 id="header" class="border-bottom pb-2">{{ 'community.delete.head' | translate
}}</h2>
<p class="pb-2">{{ 'community.delete.text' | translate:{ dso: dso.name } }}</p>
<button class="btn btn-primary mr-2" (click)="onConfirm(dso)">
{{'community.delete.confirm' |
translate}}
</button>
<button class="btn btn-primary" (click)="onCancel(dso)">{{'community.delete.cancel' | translate}}
</button>
</div>
</ng-container>
</div>
</div>

View File

@@ -0,0 +1,41 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { CommonModule } from '@angular/common';
import { RouterTestingModule } from '@angular/router/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { SharedModule } from '../../shared/shared.module';
import { of as observableOf } from 'rxjs';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { DeleteCollectionPageComponent } from './delete-collection-page.component';
import { CollectionDataService } from '../../core/data/collection-data.service';
describe('DeleteCollectionPageComponent', () => {
let comp: DeleteCollectionPageComponent;
let fixture: ComponentFixture<DeleteCollectionPageComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule],
declarations: [DeleteCollectionPageComponent],
providers: [
{ provide: CollectionDataService, useValue: {} },
{ provide: ActivatedRoute, useValue: { data: observableOf({ dso: { payload: {} } }) } },
{ provide: NotificationsService, useValue: {} },
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(DeleteCollectionPageComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
describe('frontendURL', () => {
it('should have the right frontendURL set', () => {
expect((comp as any).frontendURL).toEqual('/collections/');
})
});
});

View File

@@ -0,0 +1,33 @@
import { Component } from '@angular/core';
import { Community } from '../../core/shared/community.model';
import { CommunityDataService } from '../../core/data/community-data.service';
import { ActivatedRoute, Router } from '@angular/router';
import { NormalizedCommunity } from '../../core/cache/models/normalized-community.model';
import { DeleteComColPageComponent } from '../../shared/comcol-forms/delete-comcol-page/delete-comcol-page.component';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { CollectionDataService } from '../../core/data/collection-data.service';
import { NormalizedCollection } from '../../core/cache/models/normalized-collection.model';
import { Collection } from '../../core/shared/collection.model';
import { TranslateService } from '@ngx-translate/core';
/**
* Component that represents the page where a user can delete an existing Collection
*/
@Component({
selector: 'ds-delete-collection',
styleUrls: ['./delete-collection-page.component.scss'],
templateUrl: './delete-collection-page.component.html'
})
export class DeleteCollectionPageComponent extends DeleteComColPageComponent<Collection, NormalizedCollection> {
protected frontendURL = '/collections/';
public constructor(
protected dsoDataService: CollectionDataService,
protected router: Router,
protected route: ActivatedRoute,
protected notifications: NotificationsService,
protected translate: TranslateService
) {
super(dsoDataService, router, route, notifications, translate);
}
}

View File

@@ -0,0 +1,11 @@
<div class="container">
<div class="row">
<div class="col-12 pb-4">
<h2 id="header" class="border-bottom pb-2">{{ 'collection.edit.head' | translate }}</h2>
<ds-collection-form (submitForm)="onSubmit($event)" [dso]="(dsoRD$ | async)?.payload"></ds-collection-form>
<a class="btn btn-danger"
[routerLink]="'/collections/' + (dsoRD$ | async)?.payload.uuid + '/delete'">{{'collection.edit.delete'
| translate}}</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,39 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { CommonModule } from '@angular/common';
import { RouterTestingModule } from '@angular/router/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { EditCollectionPageComponent } from './edit-collection-page.component';
import { SharedModule } from '../../shared/shared.module';
import { CollectionDataService } from '../../core/data/collection-data.service';
import { of as observableOf } from 'rxjs';
describe('EditCollectionPageComponent', () => {
let comp: EditCollectionPageComponent;
let fixture: ComponentFixture<EditCollectionPageComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule],
declarations: [EditCollectionPageComponent],
providers: [
{ provide: CollectionDataService, useValue: {} },
{ provide: ActivatedRoute, useValue: { data: observableOf({ dso: { payload: {} } }) } },
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EditCollectionPageComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
describe('frontendURL', () => {
it('should have the right frontendURL set', () => {
expect((comp as any).frontendURL).toEqual('/collections/');
})
});
});

View File

@@ -0,0 +1,26 @@
import { Component } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { EditComColPageComponent } from '../../shared/comcol-forms/edit-comcol-page/edit-comcol-page.component';
import { NormalizedCollection } from '../../core/cache/models/normalized-collection.model';
import { Collection } from '../../core/shared/collection.model';
import { CollectionDataService } from '../../core/data/collection-data.service';
/**
* Component that represents the page where a user can edit an existing Collection
*/
@Component({
selector: 'ds-edit-collection',
styleUrls: ['./edit-collection-page.component.scss'],
templateUrl: './edit-collection-page.component.html'
})
export class EditCollectionPageComponent extends EditComColPageComponent<Collection, NormalizedCollection> {
protected frontendURL = '/collections/';
public constructor(
protected collectionDataService: CollectionDataService,
protected router: Router,
protected route: ActivatedRoute
) {
super(collectionDataService, router, route);
}
}

View File

@@ -0,0 +1,60 @@
import { Component, Input } from '@angular/core';
import { DynamicInputModel, DynamicTextAreaModel } from '@ng-dynamic-forms/core';
import { DynamicFormControlModel } from '@ng-dynamic-forms/core/src/model/dynamic-form-control.model';
import { Community } from '../../core/shared/community.model';
import { ResourceType } from '../../core/shared/resource-type';
import { ComColFormComponent } from '../../shared/comcol-forms/comcol-form/comcol-form.component';
/**
* Form used for creating and editing communities
*/
@Component({
selector: 'ds-community-form',
styleUrls: ['../../shared/comcol-forms/comcol-form/comcol-form.component.scss'],
templateUrl: '../../shared/comcol-forms/comcol-form/comcol-form.component.html'
})
export class CommunityFormComponent extends ComColFormComponent<Community> {
/**
* @type {Community} A new community when a community is being created, an existing Input community when a community is being edited
*/
@Input() dso: Community = new Community();
/**
* @type {ResourceType.Community} This is a community-type form
*/
protected type = ResourceType.Community;
/**
* The dynamic form fields used for creating/editing a community
* @type {(DynamicInputModel | DynamicTextAreaModel)[]}
*/
formModel: DynamicFormControlModel[] = [
new DynamicInputModel({
id: 'title',
name: 'dc.title',
required: true,
validators: {
required: null
},
errorMessages: {
required: 'Please enter a name for this title'
},
}),
new DynamicTextAreaModel({
id: 'description',
name: 'dc.description',
}),
new DynamicTextAreaModel({
id: 'abstract',
name: 'dc.description.abstract',
}),
new DynamicTextAreaModel({
id: 'rights',
name: 'dc.rights',
}),
new DynamicTextAreaModel({
id: 'tableofcontents',
name: 'dc.description.tableofcontents',
}),
];
}

View File

@@ -3,10 +3,38 @@ import { RouterModule } from '@angular/router';
import { CommunityPageComponent } from './community-page.component'; import { CommunityPageComponent } from './community-page.component';
import { CommunityPageResolver } from './community-page.resolver'; import { CommunityPageResolver } from './community-page.resolver';
import { CreateCommunityPageComponent } from './create-community-page/create-community-page.component';
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
import { EditCommunityPageComponent } from './edit-community-page/edit-community-page.component';
import { CreateCommunityPageGuard } from './create-community-page/create-community-page.guard';
import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component';
@NgModule({ @NgModule({
imports: [ imports: [
RouterModule.forChild([ RouterModule.forChild([
{
path: 'create',
component: CreateCommunityPageComponent,
canActivate: [AuthenticatedGuard, CreateCommunityPageGuard]
},
{
path: ':id/edit',
pathMatch: 'full',
component: EditCommunityPageComponent,
canActivate: [AuthenticatedGuard],
resolve: {
dso: CommunityPageResolver
}
},
{
path: ':id/delete',
pathMatch: 'full',
component: DeleteCommunityPageComponent,
canActivate: [AuthenticatedGuard],
resolve: {
dso: CommunityPageResolver
}
},
{ {
path: ':id', path: ':id',
component: CommunityPageComponent, component: CommunityPageComponent,
@@ -19,6 +47,7 @@ import { CommunityPageResolver } from './community-page.resolver';
], ],
providers: [ providers: [
CommunityPageResolver, CommunityPageResolver,
CreateCommunityPageGuard
] ]
}) })
export class CommunityPageRoutingModule { export class CommunityPageRoutingModule {

View File

@@ -30,6 +30,7 @@
<ds-community-page-sub-collection-list [community]="communityPayload"></ds-community-page-sub-collection-list> <ds-community-page-sub-collection-list [community]="communityPayload"></ds-community-page-sub-collection-list>
</div> </div>
</div> </div>
<ds-error *ngIf="communityRD?.hasFailed" message="{{'error.community' | translate}}"></ds-error> <ds-error *ngIf="communityRD?.hasFailed" message="{{'error.community' | translate}}"></ds-error>
<ds-loading *ngIf="communityRD?.isLoading" message="{{'loading.community' | translate}}"></ds-loading> <ds-loading *ngIf="communityRD?.isLoading" message="{{'loading.community' | translate}}"></ds-loading>
</div> </div>

View File

@@ -1,4 +1,4 @@
import {mergeMap, filter, map} from 'rxjs/operators'; import { mergeMap, filter, map } from 'rxjs/operators';
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
@@ -45,5 +45,4 @@ export class CommunityPageComponent implements OnInit, OnDestroy {
ngOnDestroy(): void { ngOnDestroy(): void {
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe()); this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
} }
} }

View File

@@ -7,6 +7,10 @@ import { CommunityPageComponent } from './community-page.component';
import { CommunityPageSubCollectionListComponent } from './sub-collection-list/community-page-sub-collection-list.component'; import { CommunityPageSubCollectionListComponent } from './sub-collection-list/community-page-sub-collection-list.component';
import { CommunityPageRoutingModule } from './community-page-routing.module'; import { CommunityPageRoutingModule } from './community-page-routing.module';
import {CommunityPageSubCommunityListComponent} from './sub-community-list/community-page-sub-community-list.component'; import {CommunityPageSubCommunityListComponent} from './sub-community-list/community-page-sub-community-list.component';
import { CreateCommunityPageComponent } from './create-community-page/create-community-page.component';
import { CommunityFormComponent } from './community-form/community-form.component';
import { EditCommunityPageComponent } from './edit-community-page/edit-community-page.component';
import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component';
@NgModule({ @NgModule({
imports: [ imports: [
@@ -18,8 +22,13 @@ import {CommunityPageSubCommunityListComponent} from './sub-community-list/commu
CommunityPageComponent, CommunityPageComponent,
CommunityPageSubCollectionListComponent, CommunityPageSubCollectionListComponent,
CommunityPageSubCommunityListComponent, CommunityPageSubCommunityListComponent,
CreateCommunityPageComponent,
EditCommunityPageComponent,
DeleteCommunityPageComponent,
CommunityFormComponent
] ]
}) })
export class CommunityPageModule { export class CommunityPageModule {
} }

View File

@@ -0,0 +1,11 @@
<div class="container">
<div class="row">
<div class="col-12 pb-4">
<ng-container *ngVar="(parentRD$ | async)?.payload as parent">
<h2 *ngIf="!parent" id="header" class="border-bottom pb-2">{{ 'community.create.head' | translate }}</h2>
<h2 *ngIf="parent" id="sub-header" class="border-bottom pb-2">{{ 'community.create.sub-head' | translate:{ parent: parent.name } }}</h2>
</ng-container>
</div>
</div>
<ds-community-form (submitForm)="onSubmit($event)"></ds-community-form>
</div>

View File

@@ -0,0 +1,42 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { CommonModule } from '@angular/common';
import { RouterTestingModule } from '@angular/router/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { RouteService } from '../../shared/services/route.service';
import { SharedModule } from '../../shared/shared.module';
import { CollectionDataService } from '../../core/data/collection-data.service';
import { of as observableOf } from 'rxjs';
import { CommunityDataService } from '../../core/data/community-data.service';
import { CreateCommunityPageComponent } from './create-community-page.component';
describe('CreateCommunityPageComponent', () => {
let comp: CreateCommunityPageComponent;
let fixture: ComponentFixture<CreateCommunityPageComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule],
declarations: [CreateCommunityPageComponent],
providers: [
{ provide: CommunityDataService, useValue: { findById: () => observableOf({}) } },
{ provide: RouteService, useValue: { getQueryParameterValue: () => observableOf('1234') } },
{ provide: Router, useValue: {} },
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CreateCommunityPageComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
describe('frontendURL', () => {
it('should have the right frontendURL set', () => {
expect((comp as any).frontendURL).toEqual('/communities/');
})
});
});

View File

@@ -0,0 +1,27 @@
import { Component } from '@angular/core';
import { Community } from '../../core/shared/community.model';
import { CommunityDataService } from '../../core/data/community-data.service';
import { RouteService } from '../../shared/services/route.service';
import { Router } from '@angular/router';
import { CreateComColPageComponent } from '../../shared/comcol-forms/create-comcol-page/create-comcol-page.component';
import { NormalizedCommunity } from '../../core/cache/models/normalized-community.model';
/**
* Component that represents the page where a user can create a new Community
*/
@Component({
selector: 'ds-create-community',
styleUrls: ['./create-community-page.component.scss'],
templateUrl: './create-community-page.component.html'
})
export class CreateCommunityPageComponent extends CreateComColPageComponent<Community, NormalizedCommunity> {
protected frontendURL = '/communities/';
public constructor(
protected communityDataService: CommunityDataService,
protected routeService: RouteService,
protected router: Router
) {
super(communityDataService, communityDataService, routeService, router);
}
}

View File

@@ -0,0 +1,67 @@
import { CreateCommunityPageGuard } from './create-community-page.guard';
import { MockRouter } from '../../shared/mocks/mock-router';
import { RemoteData } from '../../core/data/remote-data';
import { Community } from '../../core/shared/community.model';
import { of as observableOf } from 'rxjs';
import { first } from 'rxjs/operators';
describe('CreateCommunityPageGuard', () => {
describe('canActivate', () => {
let guard: CreateCommunityPageGuard;
let router;
let communityDataServiceStub: any;
beforeEach(() => {
communityDataServiceStub = {
findById: (id: string) => {
if (id === 'valid-id') {
return observableOf(new RemoteData(false, false, true, null, new Community()));
} else if (id === 'invalid-id') {
return observableOf(new RemoteData(false, false, true, null, undefined));
} else if (id === 'error-id') {
return observableOf(new RemoteData(false, false, false, null, new Community()));
}
}
};
router = new MockRouter();
guard = new CreateCommunityPageGuard(router, communityDataServiceStub);
});
it('should return true when the parent ID resolves to a community', () => {
guard.canActivate({ queryParams: { parent: 'valid-id' } } as any, undefined)
.pipe(first())
.subscribe(
(canActivate) =>
expect(canActivate).toEqual(true)
);
});
it('should return true when no parent ID has been provided', () => {
guard.canActivate({ queryParams: { } } as any, undefined)
.pipe(first())
.subscribe(
(canActivate) =>
expect(canActivate).toEqual(true)
);
});
it('should return false when the parent ID does not resolve to a community', () => {
guard.canActivate({ queryParams: { parent: 'invalid-id' } } as any, undefined)
.pipe(first())
.subscribe(
(canActivate) =>
expect(canActivate).toEqual(false)
);
});
it('should return false when the parent ID resolves to an error response', () => {
guard.canActivate({ queryParams: { parent: 'error-id' } } as any, undefined)
.pipe(first())
.subscribe(
(canActivate) =>
expect(canActivate).toEqual(false)
);
});
});
});

View File

@@ -0,0 +1,46 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router';
import { hasNoValue, hasValue } from '../../shared/empty.util';
import { CommunityDataService } from '../../core/data/community-data.service';
import { RemoteData } from '../../core/data/remote-data';
import { Community } from '../../core/shared/community.model';
import { getFinishedRemoteData } from '../../core/shared/operators';
import { map, tap } from 'rxjs/operators';
import { Observable, of as observableOf } from 'rxjs';
/**
* Prevent creation of a community with an invalid parent community provided
* @class CreateCommunityPageGuard
*/
@Injectable()
export class CreateCommunityPageGuard implements CanActivate {
public constructor(private router: Router, private communityService: CommunityDataService) {
}
/**
* True when either NO parent ID query parameter has been provided, or the parent ID resolves to a valid parent community
* Reroutes to a 404 page when the page cannot be activated
* @method canActivate
*/
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
const parentID = route.queryParams.parent;
if (hasNoValue(parentID)) {
return observableOf(true);
}
const parent: Observable<RemoteData<Community>> = this.communityService.findById(parentID)
.pipe(
getFinishedRemoteData(),
);
return parent.pipe(
map((communityRD: RemoteData<Community>) => hasValue(communityRD) && communityRD.hasSucceeded && hasValue(communityRD.payload)),
tap((isValid: boolean) => {
if (!isValid) {
this.router.navigate(['/404']);
}
})
);
}
}

View File

@@ -0,0 +1,19 @@
<div class="container">
<div class="row">
<ng-container *ngVar="(dsoRD$ | async)?.payload as dso">
<div class="col-12 pb-4">
<h2 id="header" class="border-bottom pb-2">{{ 'community.delete.head' | translate
}}</h2>
<p class="pb-2">{{ 'community.delete.text' | translate:{ dso: dso.name } }}</p>
<button class="btn btn-primary mr-2" (click)="onConfirm(dso)">
{{'community.delete.confirm' |
translate}}
</button>
<button class="btn btn-primary" (click)="onCancel(dso)">{{'community.delete.cancel' | translate}}
</button>
</div>
</ng-container>
</div>
</div>

View File

@@ -0,0 +1,42 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { CommonModule } from '@angular/common';
import { RouterTestingModule } from '@angular/router/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { RouteService } from '../../shared/services/route.service';
import { SharedModule } from '../../shared/shared.module';
import { of as observableOf } from 'rxjs';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { DeleteCommunityPageComponent } from './delete-community-page.component';
import { CommunityDataService } from '../../core/data/community-data.service';
describe('DeleteCommunityPageComponent', () => {
let comp: DeleteCommunityPageComponent;
let fixture: ComponentFixture<DeleteCommunityPageComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule],
declarations: [DeleteCommunityPageComponent],
providers: [
{ provide: CommunityDataService, useValue: {} },
{ provide: ActivatedRoute, useValue: { data: observableOf({ dso: { payload: {} } }) } },
{ provide: NotificationsService, useValue: {} },
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(DeleteCommunityPageComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
describe('frontendURL', () => {
it('should have the right frontendURL set', () => {
expect((comp as any).frontendURL).toEqual('/communities/');
})
});
});

View File

@@ -0,0 +1,30 @@
import { Component } from '@angular/core';
import { Community } from '../../core/shared/community.model';
import { CommunityDataService } from '../../core/data/community-data.service';
import { ActivatedRoute, Router } from '@angular/router';
import { NormalizedCommunity } from '../../core/cache/models/normalized-community.model';
import { DeleteComColPageComponent } from '../../shared/comcol-forms/delete-comcol-page/delete-comcol-page.component';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
/**
* Component that represents the page where a user can delete an existing Community
*/
@Component({
selector: 'ds-delete-community',
styleUrls: ['./delete-community-page.component.scss'],
templateUrl: './delete-community-page.component.html'
})
export class DeleteCommunityPageComponent extends DeleteComColPageComponent<Community, NormalizedCommunity> {
protected frontendURL = '/communities/';
public constructor(
protected dsoDataService: CommunityDataService,
protected router: Router,
protected route: ActivatedRoute,
protected notifications: NotificationsService,
protected translate: TranslateService
) {
super(dsoDataService, router, route, notifications, translate);
}
}

View File

@@ -0,0 +1,12 @@
<div class="container">
<div class="row">
<div class="col-12 pb-4">
<h2 id="header" class="border-bottom pb-2">{{ 'community.edit.head' | translate }}</h2>
<ds-community-form (submitForm)="onSubmit($event)"
[dso]="(dsoRD$ | async)?.payload"></ds-community-form>
<a class="btn btn-danger"
[routerLink]="'/communities/' + (dsoRD$ | async)?.payload.uuid + '/delete'">{{'community.edit.delete'
| translate}}</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,39 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { CommonModule } from '@angular/common';
import { RouterTestingModule } from '@angular/router/testing';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { SharedModule } from '../../shared/shared.module';
import { of as observableOf } from 'rxjs';
import { EditCommunityPageComponent } from './edit-community-page.component';
import { CommunityDataService } from '../../core/data/community-data.service';
describe('EditCommunityPageComponent', () => {
let comp: EditCommunityPageComponent;
let fixture: ComponentFixture<EditCommunityPageComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), SharedModule, CommonModule, RouterTestingModule],
declarations: [EditCommunityPageComponent],
providers: [
{ provide: CommunityDataService, useValue: {} },
{ provide: ActivatedRoute, useValue: { data: observableOf({ dso: { payload: {} } }) } },
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EditCommunityPageComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
describe('frontendURL', () => {
it('should have the right frontendURL set', () => {
expect((comp as any).frontendURL).toEqual('/communities/');
})
});
});

View File

@@ -0,0 +1,26 @@
import { Component } from '@angular/core';
import { Community } from '../../core/shared/community.model';
import { CommunityDataService } from '../../core/data/community-data.service';
import { ActivatedRoute, Router } from '@angular/router';
import { NormalizedCommunity } from '../../core/cache/models/normalized-community.model';
import { EditComColPageComponent } from '../../shared/comcol-forms/edit-comcol-page/edit-comcol-page.component';
/**
* Component that represents the page where a user can edit an existing Community
*/
@Component({
selector: 'ds-edit-community',
styleUrls: ['./edit-community-page.component.scss'],
templateUrl: './edit-community-page.component.html'
})
export class EditCommunityPageComponent extends EditComColPageComponent<Community, NormalizedCommunity> {
protected frontendURL = '/communities/';
public constructor(
protected communityDataService: CommunityDataService,
protected router: Router,
protected route: ActivatedRoute
) {
super(communityDataService, router, route);
}
}

View File

@@ -17,7 +17,6 @@ describe('SubCommunityList Component', () => {
let fixture: ComponentFixture<CommunityPageSubCommunityListComponent>; let fixture: ComponentFixture<CommunityPageSubCommunityListComponent>;
const subcommunities = [Object.assign(new Community(), { const subcommunities = [Object.assign(new Community(), {
name: 'SubCommunity 1',
id: '123456789-1', id: '123456789-1',
metadata: [ metadata: [
{ {
@@ -27,7 +26,6 @@ describe('SubCommunityList Component', () => {
}] }]
}), }),
Object.assign(new Community(), { Object.assign(new Community(), {
name: 'SubCommunity 2',
id: '123456789-2', id: '123456789-2',
metadata: [ metadata: [
{ {

View File

@@ -17,6 +17,7 @@ import { PaginationComponentOptions } from '../../shared/pagination/pagination-c
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
animations: [fadeInOut] animations: [fadeInOut]
}) })
export class TopLevelCommunityListComponent { export class TopLevelCommunityListComponent {
communitiesRDObs: Observable<RemoteData<PaginatedList<Community>>>; communitiesRDObs: Observable<RemoteData<PaginatedList<Community>>>;
config: PaginationComponentOptions; config: PaginationComponentOptions;

View File

@@ -1,22 +1,22 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import {Item} from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import {RouterStub} from '../../../shared/testing/router-stub'; import { RouterStub } from '../../../shared/testing/router-stub';
import {of as observableOf} from 'rxjs'; import { of as observableOf } from 'rxjs';
import {RestResponse} from '../../../core/cache/response-cache.models'; import { RemoteData } from '../../../core/data/remote-data';
import {RemoteData} from '../../../core/data/remote-data'; import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub';
import {NotificationsServiceStub} from '../../../shared/testing/notifications-service-stub'; import { CommonModule } from '@angular/common';
import {CommonModule} from '@angular/common'; import { FormsModule } from '@angular/forms';
import {FormsModule} from '@angular/forms'; import { RouterTestingModule } from '@angular/router/testing';
import {RouterTestingModule} from '@angular/router/testing'; import { TranslateModule } from '@ngx-translate/core';
import {TranslateModule} from '@ngx-translate/core'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; import { ActivatedRoute, Router } from '@angular/router';
import {ActivatedRoute, Router} from '@angular/router'; import { ItemDataService } from '../../../core/data/item-data.service';
import {ItemDataService} from '../../../core/data/item-data.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service';
import {NotificationsService} from '../../../shared/notifications/notifications.service'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core'; import { By } from '@angular/platform-browser';
import {By} from '@angular/platform-browser'; import { ItemDeleteComponent } from './item-delete.component';
import {ItemDeleteComponent} from './item-delete.component'; import { getItemEditPath } from '../../item-page-routing.module';
import {getItemEditPath} from '../../item-page-routing.module'; import { RestResponse } from '../../../core/cache/response.models';
let comp: ItemDeleteComponent; let comp: ItemDeleteComponent;
let fixture: ComponentFixture<ItemDeleteComponent>; let fixture: ComponentFixture<ItemDeleteComponent>;
@@ -27,8 +27,6 @@ let routerStub;
let mockItemDataService: ItemDataService; let mockItemDataService: ItemDataService;
let routeStub; let routeStub;
let notificationsServiceStub; let notificationsServiceStub;
let successfulRestResponse;
let failRestResponse;
describe('ItemDeleteComponent', () => { describe('ItemDeleteComponent', () => {
beforeEach(async(() => { beforeEach(async(() => {
@@ -46,14 +44,12 @@ describe('ItemDeleteComponent', () => {
}); });
mockItemDataService = jasmine.createSpyObj('mockItemDataService', { mockItemDataService = jasmine.createSpyObj('mockItemDataService', {
delete: observableOf(new RestResponse(true, '200')) delete: observableOf(true)
}); });
routeStub = { routeStub = {
data: observableOf({ data: observableOf({
item: new RemoteData(false, false, true, null, { item: new RemoteData(false, false, true, null, mockItem)
id: 'fake-id'
})
}) })
}; };
@@ -63,10 +59,10 @@ describe('ItemDeleteComponent', () => {
imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()],
declarations: [ItemDeleteComponent], declarations: [ItemDeleteComponent],
providers: [ providers: [
{provide: ActivatedRoute, useValue: routeStub}, { provide: ActivatedRoute, useValue: routeStub },
{provide: Router, useValue: routerStub}, { provide: Router, useValue: routerStub },
{provide: ItemDataService, useValue: mockItemDataService}, { provide: ItemDataService, useValue: mockItemDataService },
{provide: NotificationsService, useValue: notificationsServiceStub}, { provide: NotificationsService, useValue: notificationsServiceStub },
], schemas: [ ], schemas: [
CUSTOM_ELEMENTS_SCHEMA CUSTOM_ELEMENTS_SCHEMA
] ]
@@ -74,9 +70,6 @@ describe('ItemDeleteComponent', () => {
})); }));
beforeEach(() => { beforeEach(() => {
successfulRestResponse = new RestResponse(true, '200');
failRestResponse = new RestResponse(false, '500');
fixture = TestBed.createComponent(ItemDeleteComponent); fixture = TestBed.createComponent(ItemDeleteComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
fixture.detectChanges(); fixture.detectChanges();
@@ -95,22 +88,21 @@ describe('ItemDeleteComponent', () => {
describe('performAction', () => { describe('performAction', () => {
it('should call delete function from the ItemDataService', () => { it('should call delete function from the ItemDataService', () => {
spyOn(comp, 'processRestResponse'); spyOn(comp, 'notify');
comp.performAction(); comp.performAction();
expect(mockItemDataService.delete).toHaveBeenCalledWith(mockItem);
expect(mockItemDataService.delete).toHaveBeenCalledWith(mockItem.id); expect(comp.notify).toHaveBeenCalled();
expect(comp.processRestResponse).toHaveBeenCalled();
}); });
}); });
describe('processRestResponse', () => { describe('notify', () => {
it('should navigate to the homepage on successful deletion of the item', () => { it('should navigate to the homepage on successful deletion of the item', () => {
comp.processRestResponse(successfulRestResponse); comp.notify(true);
expect(routerStub.navigate).toHaveBeenCalledWith(['']); expect(routerStub.navigate).toHaveBeenCalledWith(['']);
}); });
}); });
describe('processRestResponse', () => { describe('notify', () => {
it('should navigate to the item edit page on failed deletion of the item', () => { it('should navigate to the item edit page on failed deletion of the item', () => {
comp.processRestResponse(failRestResponse); comp.notify(false);
expect(routerStub.navigate).toHaveBeenCalledWith([getItemEditPath('fake-id')]); expect(routerStub.navigate).toHaveBeenCalledWith([getItemEditPath('fake-id')]);
}); });
}); });

View File

@@ -1,8 +1,8 @@
import {Component} from '@angular/core'; import { Component } from '@angular/core';
import {first} from 'rxjs/operators'; import { first } from 'rxjs/operators';
import {RestResponse} from '../../../core/cache/response-cache.models'; import { AbstractSimpleItemActionComponent } from '../simple-item-action/abstract-simple-item-action.component';
import {AbstractSimpleItemActionComponent} from '../simple-item-action/abstract-simple-item-action.component'; import { getItemEditPath } from '../../item-page-routing.module';
import {getItemEditPath} from '../../item-page-routing.module'; import { RestResponse } from '../../../core/cache/response.models';
@Component({ @Component({
selector: 'ds-item-delete', selector: 'ds-item-delete',
@@ -19,20 +19,19 @@ export class ItemDeleteComponent extends AbstractSimpleItemActionComponent {
* Perform the delete action to the item * Perform the delete action to the item
*/ */
performAction() { performAction() {
this.itemDataService.delete(this.item.id).pipe(first()).subscribe( this.itemDataService.delete(this.item).pipe(first()).subscribe(
(response: RestResponse) => { (succeeded: boolean) => {
this.processRestResponse(response); this.notify(succeeded);
} }
); );
} }
/** /**
* Process the RestResponse retrieved from the server.
* When the item is successfully delete, navigate to the homepage, otherwise navigate back to the item edit page * When the item is successfully delete, navigate to the homepage, otherwise navigate back to the item edit page
* @param response * @param response
*/ */
processRestResponse(response: RestResponse) { notify(succeeded: boolean) {
if (response.isSuccessful) { if (succeeded) {
this.notificationsService.success(this.translateService.get('item.edit.' + this.messageKey + '.success')); this.notificationsService.success(this.translateService.get('item.edit.' + this.messageKey + '.success'));
this.router.navigate(['']); this.router.navigate(['']);
} else { } else {

View File

@@ -2,7 +2,6 @@ import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {Item} from '../../../core/shared/item.model'; import {Item} from '../../../core/shared/item.model';
import {RouterStub} from '../../../shared/testing/router-stub'; import {RouterStub} from '../../../shared/testing/router-stub';
import {of as observableOf} from 'rxjs'; import {of as observableOf} from 'rxjs';
import {RestResponse} from '../../../core/cache/response-cache.models';
import {RemoteData} from '../../../core/data/remote-data'; import {RemoteData} from '../../../core/data/remote-data';
import {NotificationsServiceStub} from '../../../shared/testing/notifications-service-stub'; import {NotificationsServiceStub} from '../../../shared/testing/notifications-service-stub';
import {CommonModule} from '@angular/common'; import {CommonModule} from '@angular/common';
@@ -16,6 +15,7 @@ import {NotificationsService} from '../../../shared/notifications/notifications.
import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core'; import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
import {By} from '@angular/platform-browser'; import {By} from '@angular/platform-browser';
import {ItemPrivateComponent} from './item-private.component'; import {ItemPrivateComponent} from './item-private.component';
import { RestResponse } from '../../../core/cache/response.models';
let comp: ItemPrivateComponent; let comp: ItemPrivateComponent;
let fixture: ComponentFixture<ItemPrivateComponent>; let fixture: ComponentFixture<ItemPrivateComponent>;

View File

@@ -1,9 +1,9 @@
import {Component} from '@angular/core'; import { Component } from '@angular/core';
import {first} from 'rxjs/operators'; import { first } from 'rxjs/operators';
import {RestResponse} from '../../../core/cache/response-cache.models'; import { AbstractSimpleItemActionComponent } from '../simple-item-action/abstract-simple-item-action.component';
import {AbstractSimpleItemActionComponent} from '../simple-item-action/abstract-simple-item-action.component'; import { RemoteData } from '../../../core/data/remote-data';
import {RemoteData} from '../../../core/data/remote-data'; import { Item } from '../../../core/shared/item.model';
import {Item} from '../../../core/shared/item.model'; import { RestResponse } from '../../../core/cache/response.models';
@Component({ @Component({
selector: 'ds-item-private', selector: 'ds-item-private',

View File

@@ -1,21 +1,21 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import {Item} from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import {RouterStub} from '../../../shared/testing/router-stub'; import { RouterStub } from '../../../shared/testing/router-stub';
import {of as observableOf} from 'rxjs'; import { of as observableOf } from 'rxjs';
import {RestResponse} from '../../../core/cache/response-cache.models'; import { RemoteData } from '../../../core/data/remote-data';
import {RemoteData} from '../../../core/data/remote-data'; import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub';
import {NotificationsServiceStub} from '../../../shared/testing/notifications-service-stub'; import { CommonModule } from '@angular/common';
import {CommonModule} from '@angular/common'; import { FormsModule } from '@angular/forms';
import {FormsModule} from '@angular/forms'; import { RouterTestingModule } from '@angular/router/testing';
import {RouterTestingModule} from '@angular/router/testing'; import { TranslateModule } from '@ngx-translate/core';
import {TranslateModule} from '@ngx-translate/core'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; import { ActivatedRoute, Router } from '@angular/router';
import {ActivatedRoute, Router} from '@angular/router'; import { ItemDataService } from '../../../core/data/item-data.service';
import {ItemDataService} from '../../../core/data/item-data.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service';
import {NotificationsService} from '../../../shared/notifications/notifications.service'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core'; import { By } from '@angular/platform-browser';
import {By} from '@angular/platform-browser'; import { ItemPublicComponent } from './item-public.component';
import {ItemPublicComponent} from './item-public.component'; import { RestResponse } from '../../../core/cache/response.models';
let comp: ItemPublicComponent; let comp: ItemPublicComponent;
let fixture: ComponentFixture<ItemPublicComponent>; let fixture: ComponentFixture<ItemPublicComponent>;

View File

@@ -1,9 +1,9 @@
import {Component} from '@angular/core'; import { Component } from '@angular/core';
import {first} from 'rxjs/operators'; import { first } from 'rxjs/operators';
import {RestResponse} from '../../../core/cache/response-cache.models'; import { AbstractSimpleItemActionComponent } from '../simple-item-action/abstract-simple-item-action.component';
import {AbstractSimpleItemActionComponent} from '../simple-item-action/abstract-simple-item-action.component'; import { RemoteData } from '../../../core/data/remote-data';
import {RemoteData} from '../../../core/data/remote-data'; import { Item } from '../../../core/shared/item.model';
import {Item} from '../../../core/shared/item.model'; import { RestResponse } from '../../../core/cache/response.models';
@Component({ @Component({
selector: 'ds-item-public', selector: 'ds-item-public',

View File

@@ -1,21 +1,21 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import {Item} from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import {RouterStub} from '../../../shared/testing/router-stub'; import { RouterStub } from '../../../shared/testing/router-stub';
import {of as observableOf} from 'rxjs'; import { of as observableOf } from 'rxjs';
import {RestResponse} from '../../../core/cache/response-cache.models'; import { RemoteData } from '../../../core/data/remote-data';
import {RemoteData} from '../../../core/data/remote-data'; import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub';
import {NotificationsServiceStub} from '../../../shared/testing/notifications-service-stub'; import { CommonModule } from '@angular/common';
import {CommonModule} from '@angular/common'; import { FormsModule } from '@angular/forms';
import {FormsModule} from '@angular/forms'; import { RouterTestingModule } from '@angular/router/testing';
import {RouterTestingModule} from '@angular/router/testing'; import { TranslateModule } from '@ngx-translate/core';
import {TranslateModule} from '@ngx-translate/core'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; import { ActivatedRoute, Router } from '@angular/router';
import {ActivatedRoute, Router} from '@angular/router'; import { ItemDataService } from '../../../core/data/item-data.service';
import {ItemDataService} from '../../../core/data/item-data.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service';
import {NotificationsService} from '../../../shared/notifications/notifications.service'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core'; import { By } from '@angular/platform-browser';
import {By} from '@angular/platform-browser'; import { ItemReinstateComponent } from './item-reinstate.component';
import {ItemReinstateComponent} from './item-reinstate.component'; import { RestResponse } from '../../../core/cache/response.models';
let comp: ItemReinstateComponent; let comp: ItemReinstateComponent;
let fixture: ComponentFixture<ItemReinstateComponent>; let fixture: ComponentFixture<ItemReinstateComponent>;

View File

@@ -1,9 +1,9 @@
import {Component} from '@angular/core'; import { Component } from '@angular/core';
import {first} from 'rxjs/operators'; import { first } from 'rxjs/operators';
import {RestResponse} from '../../../core/cache/response-cache.models'; import { AbstractSimpleItemActionComponent } from '../simple-item-action/abstract-simple-item-action.component';
import {AbstractSimpleItemActionComponent} from '../simple-item-action/abstract-simple-item-action.component'; import { RemoteData } from '../../../core/data/remote-data';
import {RemoteData} from '../../../core/data/remote-data'; import { Item } from '../../../core/shared/item.model';
import {Item} from '../../../core/shared/item.model'; import { RestResponse } from '../../../core/cache/response.models';
@Component({ @Component({
selector: 'ds-item-reinstate', selector: 'ds-item-reinstate',

View File

@@ -1,21 +1,21 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import {Item} from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import {RouterStub} from '../../../shared/testing/router-stub'; import { RouterStub } from '../../../shared/testing/router-stub';
import {of as observableOf} from 'rxjs'; import { of as observableOf } from 'rxjs';
import {RestResponse} from '../../../core/cache/response-cache.models'; import { RemoteData } from '../../../core/data/remote-data';
import {RemoteData} from '../../../core/data/remote-data'; import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub';
import {NotificationsServiceStub} from '../../../shared/testing/notifications-service-stub'; import { CommonModule } from '@angular/common';
import {CommonModule} from '@angular/common'; import { FormsModule } from '@angular/forms';
import {FormsModule} from '@angular/forms'; import { RouterTestingModule } from '@angular/router/testing';
import {RouterTestingModule} from '@angular/router/testing'; import { TranslateModule } from '@ngx-translate/core';
import {TranslateModule} from '@ngx-translate/core'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; import { ActivatedRoute, Router } from '@angular/router';
import {ActivatedRoute, Router} from '@angular/router'; import { ItemDataService } from '../../../core/data/item-data.service';
import {ItemDataService} from '../../../core/data/item-data.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service';
import {NotificationsService} from '../../../shared/notifications/notifications.service'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import {CUSTOM_ELEMENTS_SCHEMA} from '@angular/core'; import { ItemWithdrawComponent } from './item-withdraw.component';
import {ItemWithdrawComponent} from './item-withdraw.component'; import { By } from '@angular/platform-browser';
import {By} from '@angular/platform-browser'; import { RestResponse } from '../../../core/cache/response.models';
let comp: ItemWithdrawComponent; let comp: ItemWithdrawComponent;
let fixture: ComponentFixture<ItemWithdrawComponent>; let fixture: ComponentFixture<ItemWithdrawComponent>;

View File

@@ -1,9 +1,9 @@
import {Component} from '@angular/core'; import { Component } from '@angular/core';
import {first} from 'rxjs/operators'; import { first } from 'rxjs/operators';
import {RestResponse} from '../../../core/cache/response-cache.models'; import { AbstractSimpleItemActionComponent } from '../simple-item-action/abstract-simple-item-action.component';
import {AbstractSimpleItemActionComponent} from '../simple-item-action/abstract-simple-item-action.component'; import { RemoteData } from '../../../core/data/remote-data';
import {RemoteData} from '../../../core/data/remote-data'; import { Item } from '../../../core/shared/item.model';
import {Item} from '../../../core/shared/item.model'; import { RestResponse } from '../../../core/cache/response.models';
@Component({ @Component({
selector: 'ds-item-withdraw', selector: 'ds-item-withdraw',

View File

@@ -1,22 +1,22 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import {Item} from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import {RouterStub} from '../../../shared/testing/router-stub'; import { RouterStub } from '../../../shared/testing/router-stub';
import {CommonModule} from '@angular/common'; import { CommonModule } from '@angular/common';
import {RouterTestingModule} from '@angular/router/testing'; import { RouterTestingModule } from '@angular/router/testing';
import {TranslateModule} from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import {ActivatedRoute, Router} from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import {NotificationsServiceStub} from '../../../shared/testing/notifications-service-stub'; import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub';
import {NotificationsService} from '../../../shared/notifications/notifications.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service';
import {Component, CUSTOM_ELEMENTS_SCHEMA} from '@angular/core'; import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import {FormsModule} from '@angular/forms'; import { FormsModule } from '@angular/forms';
import {ItemDataService} from '../../../core/data/item-data.service'; import { ItemDataService } from '../../../core/data/item-data.service';
import {RemoteData} from '../../../core/data/remote-data'; import { RemoteData } from '../../../core/data/remote-data';
import {AbstractSimpleItemActionComponent} from './abstract-simple-item-action.component'; import { AbstractSimpleItemActionComponent } from './abstract-simple-item-action.component';
import {By} from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
import {RestResponse} from '../../../core/cache/response-cache.models'; import { of as observableOf } from 'rxjs';
import {of as observableOf} from 'rxjs'; import { getItemEditPath } from '../../item-page-routing.module';
import {getItemEditPath} from '../../item-page-routing.module'; import { RestResponse } from '../../../core/cache/response.models';
/** /**
* Test component that implements the AbstractSimpleItemActionComponent used to test the * Test component that implements the AbstractSimpleItemActionComponent used to test the

View File

@@ -8,9 +8,9 @@ import {RemoteData} from '../../../core/data/remote-data';
import {Observable} from 'rxjs'; import {Observable} from 'rxjs';
import {getSucceededRemoteData} from '../../../core/shared/operators'; import {getSucceededRemoteData} from '../../../core/shared/operators';
import {first, map} from 'rxjs/operators'; import {first, map} from 'rxjs/operators';
import {RestResponse} from '../../../core/cache/response-cache.models';
import {findSuccessfulAccordingTo} from '../edit-item-operators'; import {findSuccessfulAccordingTo} from '../edit-item-operators';
import {getItemEditPath} from '../../item-page-routing.module'; import {getItemEditPath} from '../../item-page-routing.module';
import { RestResponse } from '../../../core/cache/response.models';
/** /**
* Component to render and handle simple item edit actions such as withdrawal and reinstatement. * Component to render and handle simple item edit actions such as withdrawal and reinstatement.

View File

@@ -6,7 +6,7 @@ import {
Subject, Subject,
Subscription Subscription
} from 'rxjs'; } from 'rxjs';
import { switchMap, distinctUntilChanged, first, map } from 'rxjs/operators'; import { switchMap, distinctUntilChanged, map, take } from 'rxjs/operators';
import { animate, state, style, transition, trigger } from '@angular/animations'; import { animate, state, style, transition, trigger } from '@angular/animations';
import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; import { Component, Inject, OnDestroy, OnInit } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
@@ -126,7 +126,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
this.animationState = 'ready'; this.animationState = 'ready';
this.filterValues$.next(rd); this.filterValues$.next(rd);
})); }));
this.subs.push(newValues$.pipe(first()).subscribe((rd) => { this.subs.push(newValues$.pipe(take(1)).subscribe((rd) => {
this.isLastPage$.next(hasNoValue(rd.payload.next)) this.isLastPage$.next(hasNoValue(rd.payload.next))
})); }));
})); }));
@@ -189,7 +189,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
* @param data The string from the input field * @param data The string from the input field
*/ */
onSubmit(data: any) { onSubmit(data: any) {
this.selectedValues.pipe(first()).subscribe((selectedValues) => { this.selectedValues.pipe(take(1)).subscribe((selectedValues) => {
if (isNotEmpty(data)) { if (isNotEmpty(data)) {
this.router.navigate([this.getSearchLink()], { this.router.navigate([this.getSearchLink()], {
queryParams: queryParams:
@@ -258,7 +258,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
*/ */
findSuggestions(data): void { findSuggestions(data): void {
if (isNotEmpty(data)) { if (isNotEmpty(data)) {
this.searchConfigService.searchOptions.pipe(first()).subscribe( this.searchConfigService.searchOptions.pipe(take(1)).subscribe(
(options) => { (options) => {
this.filterSearchResults = this.searchService.getFacetValuesFor(this.filterConfig, 1, options, data.toLowerCase()) this.filterSearchResults = this.searchService.getFacetValuesFor(this.filterConfig, 1, options, data.toLowerCase())
.pipe( .pipe(

View File

@@ -1,5 +1,5 @@
import {first} from 'rxjs/operators'; import { take } from 'rxjs/operators';
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 { SearchFilterService } from './search-filter.service'; import { SearchFilterService } from './search-filter.service';
@@ -37,7 +37,7 @@ export class SearchFilterComponent implements OnInit {
* Else, the filter should initially be collapsed * Else, the filter should initially be collapsed
*/ */
ngOnInit() { ngOnInit() {
this.getSelectedValues().pipe(first()).subscribe((isActive) => { this.getSelectedValues().pipe(take(1)).subscribe((isActive) => {
if (this.filter.isOpenByDefault || isNotEmpty(isActive)) { if (this.filter.isOpenByDefault || isNotEmpty(isActive)) {
this.initialExpand(); this.initialExpand();
} else { } else {

View File

@@ -1,6 +1,4 @@
import { animate, state, style, transition, trigger } from '@angular/animations'; import { Component, OnInit } from '@angular/core';
import { Component, HostBinding, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { FilterType } from '../../../search-service/filter-type.model'; import { FilterType } from '../../../search-service/filter-type.model';
import { import {
facetLoad, facetLoad,

View File

@@ -111,7 +111,6 @@ export const objects = [
id: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', id: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f',
uuid: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', uuid: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f',
type: ResourceType.Community, type: ResourceType.Community,
name: 'OR2017 - Demonstration',
metadata: [ metadata: [
{ {
key: 'dc.description', key: 'dc.description',
@@ -161,7 +160,6 @@ export const objects = [
id: '9076bd16-e69a-48d6-9e41-0238cb40d863', id: '9076bd16-e69a-48d6-9e41-0238cb40d863',
uuid: '9076bd16-e69a-48d6-9e41-0238cb40d863', uuid: '9076bd16-e69a-48d6-9e41-0238cb40d863',
type: ResourceType.Community, type: ResourceType.Community,
name: 'Sample Community',
metadata: [ metadata: [
{ {
key: 'dc.description', key: 'dc.description',

View File

@@ -8,21 +8,18 @@ import { SearchService } from './search.service';
import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service';
import { ActivatedRoute, Router, UrlTree } from '@angular/router'; import { ActivatedRoute, Router, UrlTree } from '@angular/router';
import { RequestService } from '../../core/data/request.service'; import { RequestService } from '../../core/data/request.service';
import { ResponseCacheService } from '../../core/cache/response-cache.service';
import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; import { ActivatedRouteStub } from '../../shared/testing/active-router-stub';
import { RouterStub } from '../../shared/testing/router-stub'; import { RouterStub } from '../../shared/testing/router-stub';
import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
import { Observable, combineLatest as observableCombineLatest } from 'rxjs'; import { Observable, combineLatest as observableCombineLatest } from 'rxjs';
import { PaginatedSearchOptions } from '../paginated-search-options.model'; import { PaginatedSearchOptions } from '../paginated-search-options.model';
import { RemoteData } from '../../core/data/remote-data'; import { RemoteData } from '../../core/data/remote-data';
import { ResponseCacheEntry } from '../../core/cache/response-cache.reducer';
import { RequestEntry } from '../../core/data/request.reducer'; import { RequestEntry } from '../../core/data/request.reducer';
import { getMockRequestService } from '../../shared/mocks/mock-request.service'; import { getMockRequestService } from '../../shared/mocks/mock-request.service';
import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service';
import { import {
FacetConfigSuccessResponse, FacetConfigSuccessResponse,
SearchSuccessResponse SearchSuccessResponse
} from '../../core/cache/response-cache.models'; } from '../../core/cache/response.models';
import { SearchQueryResponse } from './search-query-response.model'; import { SearchQueryResponse } from './search-query-response.model';
import { SearchFilterConfig } from './search-filter-config.model'; import { SearchFilterConfig } from './search-filter-config.model';
import { CommunityDataService } from '../../core/data/community-data.service'; import { CommunityDataService } from '../../core/data/community-data.service';
@@ -54,7 +51,6 @@ describe('SearchService', () => {
providers: [ providers: [
{ provide: Router, useValue: router }, { provide: Router, useValue: router },
{ provide: ActivatedRoute, useValue: route }, { provide: ActivatedRoute, useValue: route },
{ provide: ResponseCacheService, useValue: getMockResponseCacheService() },
{ provide: RequestService, useValue: getMockRequestService() }, { provide: RequestService, useValue: getMockRequestService() },
{ provide: RemoteDataBuildService, useValue: {} }, { provide: RemoteDataBuildService, useValue: {} },
{ provide: HALEndpointService, useValue: {} }, { provide: HALEndpointService, useValue: {} },
@@ -86,11 +82,10 @@ describe('SearchService', () => {
}; };
const remoteDataBuildService = { const remoteDataBuildService = {
toRemoteDataObservable: (requestEntryObs: Observable<RequestEntry>, responseCacheObs: Observable<ResponseCacheEntry>, payloadObs: Observable<any>) => { toRemoteDataObservable: (requestEntryObs: Observable<RequestEntry>, payloadObs: Observable<any>) => {
return observableCombineLatest(requestEntryObs, return observableCombineLatest(requestEntryObs, payloadObs).pipe(
responseCacheObs, payloadObs).pipe( map(([req, pay]) => {
map(([req, res, pay]) => { return { req, pay };
return { req, res, pay };
}) })
); );
}, },
@@ -113,7 +108,6 @@ describe('SearchService', () => {
providers: [ providers: [
{ provide: Router, useValue: router }, { provide: Router, useValue: router },
{ provide: ActivatedRoute, useValue: route }, { provide: ActivatedRoute, useValue: route },
{ provide: ResponseCacheService, useValue: getMockResponseCacheService() },
{ provide: RequestService, useValue: getMockRequestService() }, { provide: RequestService, useValue: getMockRequestService() },
{ provide: RemoteDataBuildService, useValue: remoteDataBuildService }, { provide: RemoteDataBuildService, useValue: remoteDataBuildService },
{ provide: HALEndpointService, useValue: halService }, { provide: HALEndpointService, useValue: halService },
@@ -162,10 +156,8 @@ describe('SearchService', () => {
const searchOptions = new PaginatedSearchOptions({}); const searchOptions = new PaginatedSearchOptions({});
const queryResponse = Object.assign(new SearchQueryResponse(), { objects: [] }); const queryResponse = Object.assign(new SearchQueryResponse(), { objects: [] });
const response = new SearchSuccessResponse(queryResponse, '200'); const response = new SearchSuccessResponse(queryResponse, '200');
const responseEntry = Object.assign(new ResponseCacheEntry(), { response: response });
beforeEach(() => { beforeEach(() => {
spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(observableOf(endPoint)); spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(observableOf(endPoint));
(searchService as any).responseCache.get.and.returnValue(observableOf(responseEntry));
/* tslint:disable:no-empty */ /* tslint:disable:no-empty */
searchService.search(searchOptions).subscribe((t) => { searchService.search(searchOptions).subscribe((t) => {
}); // subscribe to make sure all methods are called }); // subscribe to make sure all methods are called
@@ -183,19 +175,14 @@ describe('SearchService', () => {
it('should call getByHref on the request service with the correct request url', () => { it('should call getByHref on the request service with the correct request url', () => {
expect((searchService as any).requestService.getByHref).toHaveBeenCalledWith(endPoint); expect((searchService as any).requestService.getByHref).toHaveBeenCalledWith(endPoint);
}); });
it('should call get on the request service with the correct request url', () => {
expect((searchService as any).responseCache.get).toHaveBeenCalledWith(endPoint);
});
}); });
describe('when getConfig is called without a scope', () => { describe('when getConfig is called without a scope', () => {
const endPoint = 'http://endpoint.com/test/config'; const endPoint = 'http://endpoint.com/test/config';
const filterConfig = [new SearchFilterConfig()]; const filterConfig = [new SearchFilterConfig()];
const response = new FacetConfigSuccessResponse(filterConfig, '200'); const response = new FacetConfigSuccessResponse(filterConfig, '200');
const responseEntry = Object.assign(new ResponseCacheEntry(), { response: response });
beforeEach(() => { beforeEach(() => {
spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(observableOf(endPoint)); spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(observableOf(endPoint));
(searchService as any).responseCache.get.and.returnValue(observableOf(responseEntry));
/* tslint:disable:no-empty */ /* tslint:disable:no-empty */
searchService.getConfig(null).subscribe((t) => { searchService.getConfig(null).subscribe((t) => {
}); // subscribe to make sure all methods are called }); // subscribe to make sure all methods are called
@@ -213,9 +200,6 @@ describe('SearchService', () => {
it('should call getByHref on the request service with the correct request url', () => { it('should call getByHref on the request service with the correct request url', () => {
expect((searchService as any).requestService.getByHref).toHaveBeenCalledWith(endPoint); expect((searchService as any).requestService.getByHref).toHaveBeenCalledWith(endPoint);
}); });
it('should call get on the request service with the correct request url', () => {
expect((searchService as any).responseCache.get).toHaveBeenCalledWith(endPoint);
});
}); });
describe('when getConfig is called with a scope', () => { describe('when getConfig is called with a scope', () => {
@@ -224,10 +208,8 @@ describe('SearchService', () => {
const requestUrl = endPoint + '?scope=' + scope; const requestUrl = endPoint + '?scope=' + scope;
const filterConfig = [new SearchFilterConfig()]; const filterConfig = [new SearchFilterConfig()];
const response = new FacetConfigSuccessResponse(filterConfig, '200'); const response = new FacetConfigSuccessResponse(filterConfig, '200');
const responseEntry = Object.assign(new ResponseCacheEntry(), { response: response });
beforeEach(() => { beforeEach(() => {
spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(observableOf(endPoint)); spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(observableOf(endPoint));
(searchService as any).responseCache.get.and.returnValue(observableOf(responseEntry));
/* tslint:disable:no-empty */ /* tslint:disable:no-empty */
searchService.getConfig(scope).subscribe((t) => { searchService.getConfig(scope).subscribe((t) => {
}); // subscribe to make sure all methods are called }); // subscribe to make sure all methods are called
@@ -245,9 +227,6 @@ describe('SearchService', () => {
it('should call getByHref on the request service with the correct request url', () => { it('should call getByHref on the request service with the correct request url', () => {
expect((searchService as any).requestService.getByHref).toHaveBeenCalledWith(requestUrl); expect((searchService as any).requestService.getByHref).toHaveBeenCalledWith(requestUrl);
}); });
it('should call get on the request service with the correct request url', () => {
expect((searchService as any).responseCache.get).toHaveBeenCalledWith(requestUrl);
});
}); });
}); });
}); });

View File

@@ -1,4 +1,4 @@
import { of as observableOf, combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs';
import { Injectable, OnDestroy } from '@angular/core'; import { Injectable, OnDestroy } from '@angular/core';
import { import {
ActivatedRoute, ActivatedRoute,
@@ -7,15 +7,13 @@ import {
Router, Router,
UrlSegmentGroup UrlSegmentGroup
} from '@angular/router'; } from '@angular/router';
import { flatMap, map, switchMap } from 'rxjs/operators'; import { map, switchMap, tap } from 'rxjs/operators';
import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service';
import { import {
FacetConfigSuccessResponse, FacetConfigSuccessResponse,
FacetValueSuccessResponse, FacetValueSuccessResponse,
SearchSuccessResponse SearchSuccessResponse
} from '../../core/cache/response-cache.models'; } from '../../core/cache/response.models';
import { ResponseCacheEntry } from '../../core/cache/response-cache.reducer';
import { ResponseCacheService } from '../../core/cache/response-cache.service';
import { PaginatedList } from '../../core/data/paginated-list'; import { PaginatedList } from '../../core/data/paginated-list';
import { ResponseParsingService } from '../../core/data/parsing.service'; import { ResponseParsingService } from '../../core/data/parsing.service';
import { RemoteData } from '../../core/data/remote-data'; import { RemoteData } from '../../core/data/remote-data';
@@ -24,7 +22,11 @@ import { RequestService } from '../../core/data/request.service';
import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { DSpaceObject } from '../../core/shared/dspace-object.model';
import { GenericConstructor } from '../../core/shared/generic-constructor'; import { GenericConstructor } from '../../core/shared/generic-constructor';
import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
import { configureRequest, getSucceededRemoteData } from '../../core/shared/operators'; import {
configureRequest,
getResponseFromEntry,
getSucceededRemoteData
} from '../../core/shared/operators';
import { URLCombiner } from '../../core/url-combiner/url-combiner'; import { URLCombiner } from '../../core/url-combiner/url-combiner';
import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util';
import { NormalizedSearchResult } from '../normalized-search-result.model'; import { NormalizedSearchResult } from '../normalized-search-result.model';
@@ -68,7 +70,6 @@ export class SearchService implements OnDestroy {
constructor(private router: Router, constructor(private router: Router,
private route: ActivatedRoute, private route: ActivatedRoute,
protected responseCache: ResponseCacheService,
protected requestService: RequestService, protected requestService: RequestService,
private rdb: RemoteDataBuildService, private rdb: RemoteDataBuildService,
private halService: HALEndpointService, private halService: HALEndpointService,
@@ -98,16 +99,12 @@ export class SearchService implements OnDestroy {
configureRequest(this.requestService) configureRequest(this.requestService)
); );
const requestEntryObs = requestObs.pipe( const requestEntryObs = requestObs.pipe(
flatMap((request: RestRequest) => this.requestService.getByHref(request.href)) switchMap((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 // get search results from response cache
const sqrObs: Observable<SearchQueryResponse> = responseCacheObs.pipe( const sqrObs: Observable<SearchQueryResponse> = requestEntryObs.pipe(
map((entry: ResponseCacheEntry) => entry.response), getResponseFromEntry(),
map((response: SearchSuccessResponse) => response.results) map((response: SearchSuccessResponse) => response.results)
); );
@@ -115,10 +112,11 @@ export class SearchService implements OnDestroy {
// Turn list of observable remote data DSO's into observable remote data object with list of DSO // Turn list of observable remote data DSO's into observable remote data object with list of DSO
const dsoObs: Observable<RemoteData<DSpaceObject[]>> = sqrObs.pipe( const dsoObs: Observable<RemoteData<DSpaceObject[]>> = sqrObs.pipe(
map((sqr: SearchQueryResponse) => { map((sqr: SearchQueryResponse) => {
return sqr.objects.map((nsr: NormalizedSearchResult) => return sqr.objects.map((nsr: NormalizedSearchResult) => {
this.rdb.buildSingle(nsr.dspaceObject)); return this.rdb.buildSingle(nsr.dspaceObject);
})
}), }),
flatMap((input: Array<Observable<RemoteData<DSpaceObject>>>) => this.rdb.aggregate(input)) switchMap((input: Array<Observable<RemoteData<DSpaceObject>>>) => this.rdb.aggregate(input)),
); );
// Create search results again with the correct dso objects linked to each result // Create search results again with the correct dso objects linked to each result
@@ -139,8 +137,8 @@ export class SearchService implements OnDestroy {
}) })
); );
const pageInfoObs: Observable<PageInfo> = responseCacheObs.pipe( const pageInfoObs: Observable<PageInfo> = requestEntryObs.pipe(
map((entry: ResponseCacheEntry) => entry.response), getResponseFromEntry(),
map((response: FacetValueSuccessResponse) => response.pageInfo) map((response: FacetValueSuccessResponse) => response.pageInfo)
); );
@@ -150,7 +148,7 @@ export class SearchService implements OnDestroy {
}) })
); );
return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs); return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs);
} }
/** /**
@@ -182,21 +180,17 @@ export class SearchService implements OnDestroy {
); );
const requestEntryObs = requestObs.pipe( const requestEntryObs = requestObs.pipe(
flatMap((request: RestRequest) => this.requestService.getByHref(request.href)) switchMap((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 // get search results from response cache
const facetConfigObs: Observable<SearchFilterConfig[]> = responseCacheObs.pipe( const facetConfigObs: Observable<SearchFilterConfig[]> = requestEntryObs.pipe(
map((entry: ResponseCacheEntry) => entry.response), getResponseFromEntry(),
map((response: FacetConfigSuccessResponse) => map((response: FacetConfigSuccessResponse) =>
response.results.map((result: any) => Object.assign(new SearchFilterConfig(), result))) response.results.map((result: any) => Object.assign(new SearchFilterConfig(), result)))
); );
return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, facetConfigObs); return this.rdb.toRemoteDataObservable(requestEntryObs, facetConfigObs);
} }
/** /**
@@ -229,21 +223,17 @@ export class SearchService implements OnDestroy {
); );
const requestEntryObs = requestObs.pipe( const requestEntryObs = requestObs.pipe(
flatMap((request: RestRequest) => this.requestService.getByHref(request.href)) switchMap((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 // get search results from response cache
const facetValueObs: Observable<FacetValue[]> = responseCacheObs.pipe( const facetValueObs: Observable<FacetValue[]> = requestEntryObs.pipe(
map((entry: ResponseCacheEntry) => entry.response), getResponseFromEntry(),
map((response: FacetValueSuccessResponse) => response.results) map((response: FacetValueSuccessResponse) => response.results)
); );
const pageInfoObs: Observable<PageInfo> = responseCacheObs.pipe( const pageInfoObs: Observable<PageInfo> = requestEntryObs.pipe(
map((entry: ResponseCacheEntry) => entry.response), getResponseFromEntry(),
map((response: FacetValueSuccessResponse) => response.pageInfo) map((response: FacetValueSuccessResponse) => response.pageInfo)
); );
@@ -253,7 +243,7 @@ export class SearchService implements OnDestroy {
}) })
); );
return this.rdb.toRemoteDataObservable(requestEntryObs, responseCacheObs, payloadObs); return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs);
} }
/** /**

View File

@@ -1,4 +1,4 @@
import { filter, first, map, take } from 'rxjs/operators'; import { filter, map, take } from 'rxjs/operators';
import { import {
AfterViewInit, AfterViewInit,
ChangeDetectionStrategy, ChangeDetectionStrategy,
@@ -91,7 +91,7 @@ export class AppComponent implements OnInit, AfterViewInit {
// Whether is not authenticathed try to retrieve a possible stored auth token // Whether is not authenticathed try to retrieve a possible stored auth token
this.store.pipe(select(isAuthenticated), this.store.pipe(select(isAuthenticated),
first(), take(1),
filter((authenticated) => !authenticated) filter((authenticated) => !authenticated)
).subscribe((authenticated) => this.authService.checkAuthenticationToken()); ).subscribe((authenticated) => this.authService.checkAuthenticationToken());
this.sidebarVisible = this.menuService.isMenuVisible(MenuID.ADMIN); this.sidebarVisible = this.menuService.isMenuVisible(MenuID.ADMIN);

View File

@@ -2,15 +2,15 @@ import { Observable, of as observableOf, throwError as observableThrowError } fr
import { distinctUntilChanged, filter, map, mergeMap, tap } from 'rxjs/operators'; import { distinctUntilChanged, filter, map, mergeMap, tap } from 'rxjs/operators';
import { Inject, Injectable } from '@angular/core'; import { Inject, Injectable } from '@angular/core';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.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 { GLOBAL_CONFIG } from '../../../config';
import { GlobalConfig } from '../../../config/global-config.interface'; import { GlobalConfig } from '../../../config/global-config.interface';
import { isNotEmpty } from '../../shared/empty.util'; import { isNotEmpty } from '../../shared/empty.util';
import { AuthGetRequest, AuthPostRequest, PostRequest, RestRequest } from '../data/request.models'; import { AuthGetRequest, AuthPostRequest, PostRequest, RestRequest } from '../data/request.models';
import { ResponseCacheEntry } from '../cache/response-cache.reducer'; import { AuthStatusResponse, ErrorResponse } from '../cache/response.models';
import { AuthStatusResponse, ErrorResponse } from '../cache/response-cache.models';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { RequestEntry } from '../data/request.reducer';
import { getResponseFromEntry } from '../shared/operators';
@Injectable() @Injectable()
export class AuthRequestService { export class AuthRequestService {
@@ -19,18 +19,15 @@ export class AuthRequestService {
constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
protected halService: HALEndpointService, protected halService: HALEndpointService,
protected responseCache: ResponseCacheService,
protected requestService: RequestService) { protected requestService: RequestService) {
} }
protected fetchRequest(request: RestRequest): Observable<any> { protected fetchRequest(request: RestRequest): Observable<any> {
return this.responseCache.get(request.href).pipe( return this.requestService.getByUUID(request.uuid).pipe(
map((entry: ResponseCacheEntry) => entry.response), getResponseFromEntry(),
// TODO to review when https://github.com/DSpace/dspace-angular/issues/217 will be fixed
tap(() => this.responseCache.remove(request.href)),
mergeMap((response) => { mergeMap((response) => {
if (response.isSuccessful && isNotEmpty(response)) { if (response.isSuccessful && isNotEmpty(response)) {
return observableOf((response as AuthStatusResponse).response); return observableOf((response as AuthStatusResponse).response);
} else if (!response.isSuccessful) { } else if (!response.isSuccessful) {
return observableThrowError(new Error((response as ErrorResponse).errorMessage)); return observableThrowError(new Error((response as ErrorResponse).errorMessage));
} }

View File

@@ -1,7 +1,6 @@
import { AuthStatusResponse } from '../cache/response-cache.models'; import { AuthStatusResponse } from '../cache/response.models';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { GlobalConfig } from '../../../config/global-config.interface';
import { AuthStatus } from './models/auth-status.model'; import { AuthStatus } from './models/auth-status.model';
import { AuthResponseParsingService } from './auth-response-parsing.service'; import { AuthResponseParsingService } from './auth-response-parsing.service';
import { AuthGetRequest, AuthPostRequest } from '../data/request.models'; import { AuthGetRequest, AuthPostRequest } from '../data/request.models';
@@ -11,7 +10,7 @@ import { ObjectCacheState } from '../cache/object-cache.reducer';
describe('AuthResponseParsingService', () => { describe('AuthResponseParsingService', () => {
let service: AuthResponseParsingService; let service: AuthResponseParsingService;
const EnvConfig = { cache: { msToLive: 1000 } } as GlobalConfig; const EnvConfig = { cache: { msToLive: 1000 } } as any;
const store = new MockStore<ObjectCacheState>({}); const store = new MockStore<ObjectCacheState>({});
const objectCacheService = new ObjectCacheService(store as any); const objectCacheService = new ObjectCacheService(store as any);

View File

@@ -2,7 +2,7 @@ import { Inject, Injectable } from '@angular/core';
import { AuthObjectFactory } from './auth-object-factory'; import { AuthObjectFactory } from './auth-object-factory';
import { BaseResponseParsingService } from '../data/base-response-parsing.service'; import { BaseResponseParsingService } from '../data/base-response-parsing.service';
import { AuthStatusResponse, RestResponse } from '../cache/response-cache.models'; import { AuthStatusResponse, RestResponse } from '../cache/response.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { GLOBAL_CONFIG } from '../../../config'; import { GLOBAL_CONFIG } from '../../../config';
import { GlobalConfig } from '../../../config/global-config.interface'; import { GlobalConfig } from '../../../config/global-config.interface';
@@ -27,7 +27,7 @@ export class AuthResponseParsingService extends BaseResponseParsingService imple
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && (data.statusCode === '200' || data.statusCode === 'OK')) { if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && (data.statusCode === '200' || data.statusCode === 'OK')) {
const response = this.process<NormalizedAuthStatus, AuthType>(data.payload, request.href); const response = this.process<NormalizedAuthStatus, AuthType>(data.payload, request.uuid);
return new AuthStatusResponse(response, data.statusCode); return new AuthStatusResponse(response, data.statusCode);
} else { } else {
return new AuthStatusResponse(data.payload as AuthStatus, data.statusCode); return new AuthStatusResponse(data.payload as AuthStatus, data.statusCode);

View File

@@ -1,6 +1,6 @@
import { of as observableOf, Observable } from 'rxjs'; import { of as observableOf, Observable } from 'rxjs';
import { filter, debounceTime, switchMap, take, tap, catchError, map, first } from 'rxjs/operators'; import { filter, debounceTime, switchMap, take, tap, catchError, map } from 'rxjs/operators';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
// import @ngrx // import @ngrx
@@ -47,7 +47,7 @@ export class AuthEffects {
ofType(AuthActionTypes.AUTHENTICATE), ofType(AuthActionTypes.AUTHENTICATE),
switchMap((action: AuthenticateAction) => { switchMap((action: AuthenticateAction) => {
return this.authService.authenticate(action.payload.email, action.payload.password).pipe( return this.authService.authenticate(action.payload.email, action.payload.password).pipe(
first(), take(1),
map((response: AuthStatus) => new AuthenticationSuccessAction(response.token)), map((response: AuthStatus) => new AuthenticationSuccessAction(response.token)),
catchError((error) => observableOf(new AuthenticationErrorAction(error))) catchError((error) => observableOf(new AuthenticationErrorAction(error)))
); );
@@ -127,7 +127,7 @@ export class AuthEffects {
switchMap(() => { switchMap(() => {
return this.store.pipe( return this.store.pipe(
select(isAuthenticated), select(isAuthenticated),
first(), take(1),
filter((authenticated) => !authenticated), filter((authenticated) => !authenticated),
tap(() => this.authService.removeToken()), tap(() => this.authService.removeToken()),
tap(() => this.authService.resetAuthenticationError()) tap(() => this.authService.resetAuthenticationError())

View File

@@ -9,10 +9,10 @@ import { of as observableOf } from 'rxjs';
import { AuthInterceptor } from './auth.interceptor'; import { AuthInterceptor } from './auth.interceptor';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { DSpaceRESTv2Service } from '../dspace-rest-v2/dspace-rest-v2.service'; import { DSpaceRESTv2Service } from '../dspace-rest-v2/dspace-rest-v2.service';
import { RestRequestMethod } from '../data/request.models';
import { RouterStub } from '../../shared/testing/router-stub'; import { RouterStub } from '../../shared/testing/router-stub';
import { TruncatablesState } from '../../shared/truncatable/truncatable.reducer'; import { TruncatablesState } from '../../shared/truncatable/truncatable.reducer';
import { AuthServiceStub } from '../../shared/testing/auth-service-stub'; import { AuthServiceStub } from '../../shared/testing/auth-service-stub';
import { RestRequestMethod } from '../data/rest-request-method';
describe(`AuthInterceptor`, () => { describe(`AuthInterceptor`, () => {
let service: DSpaceRESTv2Service; let service: DSpaceRESTv2Service;
@@ -49,7 +49,7 @@ describe(`AuthInterceptor`, () => {
describe('when has a valid token', () => { describe('when has a valid token', () => {
it('should not add an Authorization header when were sending a HTTP request to \'authn\' endpoint', () => { it('should not add an Authorization header when were sending a HTTP request to \'authn\' endpoint', () => {
service.request(RestRequestMethod.Post, 'dspace-spring-rest/api/authn/login', 'password=password&user=user').subscribe((response) => { service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/authn/login', 'password=password&user=user').subscribe((response) => {
expect(response).toBeTruthy(); expect(response).toBeTruthy();
}); });
@@ -60,7 +60,7 @@ describe(`AuthInterceptor`, () => {
}); });
it('should add an Authorization header when were sending a HTTP request to \'authn\' endpoint', () => { it('should add an Authorization header when were sending a HTTP request to \'authn\' endpoint', () => {
service.request(RestRequestMethod.Post, 'dspace-spring-rest/api/submission/workspaceitems', 'test').subscribe((response) => { service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/submission/workspaceitems', 'test').subscribe((response) => {
expect(response).toBeTruthy(); expect(response).toBeTruthy();
}); });
@@ -85,11 +85,11 @@ describe(`AuthInterceptor`, () => {
it('should redirect to login', () => { it('should redirect to login', () => {
service.request(RestRequestMethod.Post, 'dspace-spring-rest/api/submission/workspaceitems', 'password=password&user=user').subscribe((response) => { service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/submission/workspaceitems', 'password=password&user=user').subscribe((response) => {
expect(response).toBeTruthy(); expect(response).toBeTruthy();
}); });
service.request(RestRequestMethod.Post, 'dspace-spring-rest/api/submission/workspaceitems', 'password=password&user=user'); service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/submission/workspaceitems', 'password=password&user=user');
httpMock.expectNone('dspace-spring-rest/api/submission/workspaceitems'); httpMock.expectNone('dspace-spring-rest/api/submission/workspaceitems');
}); });

View File

@@ -277,7 +277,7 @@ export class AuthService {
public isTokenExpiring(): Observable<boolean> { public isTokenExpiring(): Observable<boolean> {
return this.store.pipe( return this.store.pipe(
select(isTokenRefreshing), select(isTokenRefreshing),
first(), take(1),
map((isRefreshing: boolean) => { map((isRefreshing: boolean) => {
if (this.isTokenExpired() || isRefreshing) { if (this.isTokenExpired() || isRefreshing) {
return false; return false;
@@ -360,7 +360,7 @@ export class AuthService {
*/ */
public redirectToPreviousUrl() { public redirectToPreviousUrl() {
this.getRedirectUrl().pipe( this.getRedirectUrl().pipe(
first()) take(1))
.subscribe((redirectUrl) => { .subscribe((redirectUrl) => {
if (isNotEmpty(redirectUrl)) { if (isNotEmpty(redirectUrl)) {

View File

@@ -1,4 +1,4 @@
import { first, map, switchMap } from 'rxjs/operators'; import { map, switchMap, take } from 'rxjs/operators';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@@ -45,7 +45,7 @@ export class ServerAuthService extends AuthService {
} else { } else {
throw(new Error('Not authenticated')); throw(new Error('Not authenticated'));
} }
})) }));
} }
/** /**
@@ -60,7 +60,7 @@ export class ServerAuthService extends AuthService {
*/ */
public redirectToPreviousUrl() { public redirectToPreviousUrl() {
this.getRedirectUrl().pipe( this.getRedirectUrl().pipe(
first()) take(1))
.subscribe((redirectUrl) => { .subscribe((redirectUrl) => {
if (isNotEmpty(redirectUrl)) { if (isNotEmpty(redirectUrl)) {
// override the route reuse strategy // override the route reuse strategy

View File

@@ -2,20 +2,19 @@ import { cold, getTestScheduler, hot } from 'jasmine-marbles';
import { TestScheduler } from 'rxjs/testing'; import { TestScheduler } from 'rxjs/testing';
import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service'; import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service';
import { getMockRequestService } from '../../shared/mocks/mock-request.service'; import { getMockRequestService } from '../../shared/mocks/mock-request.service';
import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub';
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 { BrowseEndpointRequest, BrowseEntriesRequest, BrowseItemsRequest } from '../data/request.models'; import { BrowseEndpointRequest, BrowseEntriesRequest, BrowseItemsRequest } from '../data/request.models';
import { RequestService } from '../data/request.service'; import { RequestService } from '../data/request.service';
import { BrowseDefinition } from '../shared/browse-definition.model'; import { BrowseDefinition } from '../shared/browse-definition.model';
import { BrowseService } from './browse.service'; import { BrowseService } from './browse.service';
import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; import { BrowseEntrySearchOptions } from './browse-entry-search-options.model';
import { RequestEntry } from '../data/request.reducer';
import { of as observableOf } from 'rxjs';
describe('BrowseService', () => { describe('BrowseService', () => {
let scheduler: TestScheduler; let scheduler: TestScheduler;
let service: BrowseService; let service: BrowseService;
let responseCache: ResponseCacheService;
let requestService: RequestService; let requestService: RequestService;
let rdbService: RemoteDataBuildService; let rdbService: RemoteDataBuildService;
@@ -80,22 +79,14 @@ describe('BrowseService', () => {
}) })
]; ];
function initMockResponseCacheService(isSuccessful: boolean) { const getRequestEntry$ = (successful: boolean) => {
const rcs = getMockResponseCacheService(); return observableOf({
(rcs.get as any).and.returnValue(cold('b-', { response: { isSuccessful: successful, payload: browseDefinitions } as any
b: { } as RequestEntry)
response: { };
isSuccessful,
payload: browseDefinitions,
}
}
}));
return rcs;
}
function initTestService() { function initTestService() {
return new BrowseService( return new BrowseService(
responseCache,
requestService, requestService,
halService, halService,
rdbService rdbService
@@ -109,8 +100,7 @@ describe('BrowseService', () => {
describe('getBrowseDefinitions', () => { describe('getBrowseDefinitions', () => {
beforeEach(() => { beforeEach(() => {
responseCache = initMockResponseCacheService(true); requestService = getMockRequestService(getRequestEntry$(true));
requestService = getMockRequestService();
rdbService = getMockRemoteDataBuildService(); rdbService = getMockRemoteDataBuildService();
service = initTestService(); service = initTestService();
spyOn(halService, 'getEndpoint').and spyOn(halService, 'getEndpoint').and
@@ -148,8 +138,7 @@ describe('BrowseService', () => {
const mockAuthorName = 'Donald Smith'; const mockAuthorName = 'Donald Smith';
beforeEach(() => { beforeEach(() => {
responseCache = initMockResponseCacheService(true); requestService = getMockRequestService(getRequestEntry$(true));
requestService = getMockRequestService();
rdbService = getMockRemoteDataBuildService(); rdbService = getMockRemoteDataBuildService();
service = initTestService(); service = initTestService();
spyOn(service, 'getBrowseDefinitions').and spyOn(service, 'getBrowseDefinitions').and
@@ -222,8 +211,7 @@ describe('BrowseService', () => {
describe('if getBrowseDefinitions fires', () => { describe('if getBrowseDefinitions fires', () => {
beforeEach(() => { beforeEach(() => {
responseCache = initMockResponseCacheService(true); requestService = getMockRequestService(getRequestEntry$(true));
requestService = getMockRequestService();
rdbService = getMockRemoteDataBuildService(); rdbService = getMockRemoteDataBuildService();
service = initTestService(); service = initTestService();
spyOn(service, 'getBrowseDefinitions').and spyOn(service, 'getBrowseDefinitions').and
@@ -278,8 +266,7 @@ describe('BrowseService', () => {
describe('if getBrowseDefinitions doesn\'t fire', () => { describe('if getBrowseDefinitions doesn\'t fire', () => {
it('should return undefined', () => { it('should return undefined', () => {
responseCache = initMockResponseCacheService(true); requestService = getMockRequestService(getRequestEntry$(true));
requestService = getMockRequestService();
rdbService = getMockRemoteDataBuildService(); rdbService = getMockRemoteDataBuildService();
service = initTestService(); service = initTestService();
spyOn(service, 'getBrowseDefinitions').and spyOn(service, 'getBrowseDefinitions').and
@@ -297,7 +284,6 @@ describe('BrowseService', () => {
describe('getFirstItemFor', () => { describe('getFirstItemFor', () => {
beforeEach(() => { beforeEach(() => {
responseCache = initMockResponseCacheService(true);
requestService = getMockRequestService(); requestService = getMockRequestService();
rdbService = getMockRemoteDataBuildService(); rdbService = getMockRemoteDataBuildService();
service = initTestService(); service = initTestService();

View File

@@ -9,9 +9,6 @@ import {
isNotEmptyOperator isNotEmptyOperator
} from '../../shared/empty.util'; } from '../../shared/empty.util';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { GenericSuccessResponse } from '../cache/response-cache.models';
import { ResponseCacheEntry } from '../cache/response-cache.reducer';
import { ResponseCacheService } from '../cache/response-cache.service';
import { PaginatedList } from '../data/paginated-list'; import { PaginatedList } from '../data/paginated-list';
import { RemoteData } from '../data/remote-data'; import { RemoteData } from '../data/remote-data';
import { import {
@@ -28,13 +25,14 @@ import {
configureRequest, configureRequest,
filterSuccessfulResponses, getBrowseDefinitionLinks, getFirstOccurrence, filterSuccessfulResponses, getBrowseDefinitionLinks, getFirstOccurrence,
getRemoteDataPayload, getRemoteDataPayload,
getRequestFromSelflink, getRequestFromRequestHref
getResponseFromSelflink
} from '../shared/operators'; } from '../shared/operators';
import { URLCombiner } from '../url-combiner/url-combiner'; import { URLCombiner } from '../url-combiner/url-combiner';
import { Item } from '../shared/item.model'; import { Item } from '../shared/item.model';
import { DSpaceObject } from '../shared/dspace-object.model'; import { DSpaceObject } from '../shared/dspace-object.model';
import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; import { BrowseEntrySearchOptions } from './browse-entry-search-options.model';
import { GenericSuccessResponse } from '../cache/response.models';
import { RequestEntry } from '../data/request.reducer';
/** /**
* The service handling all browse requests * The service handling all browse requests
@@ -57,7 +55,6 @@ export class BrowseService {
} }
constructor( constructor(
protected responseCache: ResponseCacheService,
protected requestService: RequestService, protected requestService: RequestService,
protected halService: HALEndpointService, protected halService: HALEndpointService,
private rdb: RemoteDataBuildService, private rdb: RemoteDataBuildService,
@@ -76,11 +73,9 @@ export class BrowseService {
); );
const href$ = request$.pipe(map((request: RestRequest) => request.href)); const href$ = request$.pipe(map((request: RestRequest) => request.href));
const requestEntry$ = href$.pipe(getRequestFromSelflink(this.requestService)); const requestEntry$ = href$.pipe(getRequestFromRequestHref(this.requestService));
const responseCache$ = href$.pipe(getResponseFromSelflink(this.responseCache)); const payload$ = requestEntry$.pipe(
const payload$ = responseCache$.pipe(
filterSuccessfulResponses(), filterSuccessfulResponses(),
map((entry: ResponseCacheEntry) => entry.response),
map((response: GenericSuccessResponse<BrowseDefinition[]>) => response.payload), map((response: GenericSuccessResponse<BrowseDefinition[]>) => response.payload),
ensureArrayHasValue(), ensureArrayHasValue(),
map((definitions: BrowseDefinition[]) => definitions map((definitions: BrowseDefinition[]) => definitions
@@ -88,7 +83,7 @@ export class BrowseService {
distinctUntilChanged() distinctUntilChanged()
); );
return this.rdb.toRemoteDataObservable(requestEntry$, responseCache$, payload$); return this.rdb.toRemoteDataObservable(requestEntry$, payload$);
} }
/** /**
@@ -122,7 +117,7 @@ export class BrowseService {
} }
return href; return href;
}), }),
getBrowseEntriesFor(this.requestService, this.responseCache, this.rdb) getBrowseEntriesFor(this.requestService, this.rdb)
); );
} }
@@ -161,7 +156,7 @@ export class BrowseService {
} }
return href; return href;
}), }),
getBrowseItemsFor(this.requestService, this.responseCache, this.rdb) getBrowseItemsFor(this.requestService, this.rdb)
); );
} }
@@ -188,7 +183,7 @@ export class BrowseService {
} }
return href; return href;
}), }),
getBrowseItemsFor(this.requestService, this.responseCache, this.rdb), getBrowseItemsFor(this.requestService, this.rdb),
getFirstOccurrence() getFirstOccurrence()
); );
} }
@@ -199,7 +194,7 @@ export class BrowseService {
*/ */
getPrevBrowseItems(items: RemoteData<PaginatedList<Item>>): Observable<RemoteData<PaginatedList<Item>>> { getPrevBrowseItems(items: RemoteData<PaginatedList<Item>>): Observable<RemoteData<PaginatedList<Item>>> {
return observableOf(items.payload.prev).pipe( return observableOf(items.payload.prev).pipe(
getBrowseItemsFor(this.requestService, this.responseCache, this.rdb) getBrowseItemsFor(this.requestService, this.rdb)
); );
} }
@@ -209,7 +204,7 @@ export class BrowseService {
*/ */
getNextBrowseItems(items: RemoteData<PaginatedList<Item>>): Observable<RemoteData<PaginatedList<Item>>> { getNextBrowseItems(items: RemoteData<PaginatedList<Item>>): Observable<RemoteData<PaginatedList<Item>>> {
return observableOf(items.payload.next).pipe( return observableOf(items.payload.next).pipe(
getBrowseItemsFor(this.requestService, this.responseCache, this.rdb) getBrowseItemsFor(this.requestService, this.rdb)
); );
} }
@@ -219,7 +214,7 @@ export class BrowseService {
*/ */
getPrevBrowseEntries(entries: RemoteData<PaginatedList<BrowseEntry>>): Observable<RemoteData<PaginatedList<BrowseEntry>>> { getPrevBrowseEntries(entries: RemoteData<PaginatedList<BrowseEntry>>): Observable<RemoteData<PaginatedList<BrowseEntry>>> {
return observableOf(entries.payload.prev).pipe( return observableOf(entries.payload.prev).pipe(
getBrowseEntriesFor(this.requestService, this.responseCache, this.rdb) getBrowseEntriesFor(this.requestService, this.rdb)
); );
} }
@@ -229,7 +224,7 @@ export class BrowseService {
*/ */
getNextBrowseEntries(entries: RemoteData<PaginatedList<BrowseEntry>>): Observable<RemoteData<PaginatedList<BrowseEntry>>> { getNextBrowseEntries(entries: RemoteData<PaginatedList<BrowseEntry>>): Observable<RemoteData<PaginatedList<BrowseEntry>>> {
return observableOf(entries.payload.next).pipe( return observableOf(entries.payload.next).pipe(
getBrowseEntriesFor(this.requestService, this.responseCache, this.rdb) getBrowseEntriesFor(this.requestService, this.rdb)
); );
} }
@@ -268,12 +263,12 @@ export class BrowseService {
* @param responseCache * @param responseCache
* @param rdb * @param rdb
*/ */
export const getBrowseEntriesFor = (requestService: RequestService, responseCache: ResponseCacheService, rdb: RemoteDataBuildService) => export const getBrowseEntriesFor = (requestService: RequestService, rdb: RemoteDataBuildService) =>
(source: Observable<string>): Observable<RemoteData<PaginatedList<BrowseEntry>>> => (source: Observable<string>): Observable<RemoteData<PaginatedList<BrowseEntry>>> =>
source.pipe( source.pipe(
map((href: string) => new BrowseEntriesRequest(requestService.generateRequestId(), href)), map((href: string) => new BrowseEntriesRequest(requestService.generateRequestId(), href)),
configureRequest(requestService), configureRequest(requestService),
toRDPaginatedBrowseEntries(requestService, responseCache, rdb) toRDPaginatedBrowseEntries(requestService, rdb)
); );
/** /**
@@ -282,12 +277,12 @@ export const getBrowseEntriesFor = (requestService: RequestService, responseCach
* @param responseCache * @param responseCache
* @param rdb * @param rdb
*/ */
export const getBrowseItemsFor = (requestService: RequestService, responseCache: ResponseCacheService, rdb: RemoteDataBuildService) => export const getBrowseItemsFor = (requestService: RequestService, rdb: RemoteDataBuildService) =>
(source: Observable<string>): Observable<RemoteData<PaginatedList<Item>>> => (source: Observable<string>): Observable<RemoteData<PaginatedList<Item>>> =>
source.pipe( source.pipe(
map((href: string) => new BrowseItemsRequest(requestService.generateRequestId(), href)), map((href: string) => new BrowseItemsRequest(requestService.generateRequestId(), href)),
configureRequest(requestService), configureRequest(requestService),
toRDPaginatedBrowseItems(requestService, responseCache, rdb) toRDPaginatedBrowseItems(requestService, rdb)
); );
/** /**
@@ -296,16 +291,14 @@ export const getBrowseItemsFor = (requestService: RequestService, responseCache:
* @param responseCache * @param responseCache
* @param rdb * @param rdb
*/ */
export const toRDPaginatedBrowseItems = (requestService: RequestService, responseCache: ResponseCacheService, rdb: RemoteDataBuildService) => export const toRDPaginatedBrowseItems = (requestService: RequestService, rdb: RemoteDataBuildService) =>
(source: Observable<RestRequest>): Observable<RemoteData<PaginatedList<Item>>> => { (source: Observable<RestRequest>): Observable<RemoteData<PaginatedList<Item>>> => {
const href$ = source.pipe(map((request: RestRequest) => request.href)); const href$ = source.pipe(map((request: RestRequest) => request.href));
const requestEntry$ = href$.pipe(getRequestFromSelflink(requestService)); const requestEntry$ = href$.pipe(getRequestFromRequestHref(requestService));
const responseCache$ = href$.pipe(getResponseFromSelflink(responseCache));
const payload$ = responseCache$.pipe( const payload$ = requestEntry$.pipe(
filterSuccessfulResponses(), filterSuccessfulResponses(),
map((entry: ResponseCacheEntry) => entry.response),
map((response: GenericSuccessResponse<Item[]>) => new PaginatedList(response.pageInfo, response.payload)), map((response: GenericSuccessResponse<Item[]>) => new PaginatedList(response.pageInfo, response.payload)),
map((list: PaginatedList<Item>) => Object.assign(list, { map((list: PaginatedList<Item>) => Object.assign(list, {
page: list.page ? list.page.map((item: DSpaceObject) => Object.assign(new Item(), item)) : list.page page: list.page ? list.page.map((item: DSpaceObject) => Object.assign(new Item(), item)) : list.page
@@ -313,7 +306,7 @@ export const toRDPaginatedBrowseItems = (requestService: RequestService, respons
distinctUntilChanged() distinctUntilChanged()
); );
return rdb.toRemoteDataObservable(requestEntry$, responseCache$, payload$); return rdb.toRemoteDataObservable(requestEntry$, payload$);
}; };
/** /**
@@ -322,16 +315,14 @@ export const toRDPaginatedBrowseItems = (requestService: RequestService, respons
* @param responseCache * @param responseCache
* @param rdb * @param rdb
*/ */
export const toRDPaginatedBrowseEntries = (requestService: RequestService, responseCache: ResponseCacheService, rdb: RemoteDataBuildService) => export const toRDPaginatedBrowseEntries = (requestService: RequestService, rdb: RemoteDataBuildService) =>
(source: Observable<RestRequest>): Observable<RemoteData<PaginatedList<BrowseEntry>>> => { (source: Observable<RestRequest>): Observable<RemoteData<PaginatedList<BrowseEntry>>> => {
const href$ = source.pipe(map((request: RestRequest) => request.href)); const href$ = source.pipe(map((request: RestRequest) => request.href));
const requestEntry$ = href$.pipe(getRequestFromSelflink(requestService)); const requestEntry$ = href$.pipe(getRequestFromRequestHref(requestService));
const responseCache$ = href$.pipe(getResponseFromSelflink(responseCache));
const payload$ = responseCache$.pipe( const payload$ = requestEntry$.pipe(
filterSuccessfulResponses(), filterSuccessfulResponses(),
map((entry: ResponseCacheEntry) => entry.response),
map((response: GenericSuccessResponse<BrowseEntry[]>) => new PaginatedList(response.pageInfo, response.payload)), map((response: GenericSuccessResponse<BrowseEntry[]>) => new PaginatedList(response.pageInfo, response.payload)),
map((list: PaginatedList<BrowseEntry>) => Object.assign(list, { map((list: PaginatedList<BrowseEntry>) => Object.assign(list, {
page: list.page ? list.page.map((entry: BrowseEntry) => Object.assign(new BrowseEntry(), entry)) : list.page page: list.page ? list.page.map((entry: BrowseEntry) => Object.assign(new BrowseEntry(), entry)) : list.page
@@ -339,5 +330,5 @@ export const toRDPaginatedBrowseEntries = (requestService: RequestService, respo
distinctUntilChanged() distinctUntilChanged()
); );
return rdb.toRemoteDataObservable(requestEntry$, responseCache$, payload$); return rdb.toRemoteDataObservable(requestEntry$, payload$);
}; };

View File

@@ -0,0 +1,50 @@
import { Injectable } from '@angular/core';
import { NormalizedObject } from '../models/normalized-object.model';
import { CacheableObject } from '../object-cache.reducer';
import { getRelationships } from './build-decorators';
import { NormalizedObjectFactory } from '../models/normalized-object-factory';
import { hasValue, isNotEmpty } from '../../../shared/empty.util';
/**
* Return true if halObj has a value for `_links.self`
*
* @param {any} halObj The object to test
*/
export function isRestDataObject(halObj: any): boolean {
return isNotEmpty(halObj._links) && hasValue(halObj._links.self);
}
/**
* Return true if halObj has a value for `page` and `_embedded`
*
* @param {any} halObj The object to test
*/
export function isRestPaginatedList(halObj: any): boolean {
return hasValue(halObj.page) && hasValue(halObj._embedded);
}
/**
* A service to turn domain models in to their normalized
* counterparts.
*/
@Injectable()
export class NormalizedObjectBuildService {
/**
* Returns the normalized model that corresponds to the given domain model
*
* @param {TDomain} domainModel a domain model
*/
normalize<TDomain extends CacheableObject, TNormalized extends NormalizedObject>(domainModel: TDomain): TNormalized {
const normalizedConstructor = NormalizedObjectFactory.getConstructor(domainModel.type);
const relationships = getRelationships(normalizedConstructor) || [];
const normalizedModel = Object.assign({}, domainModel) as any;
relationships.forEach((key: string) => {
if (hasValue(domainModel[key])) {
domainModel[key] = undefined;
}
});
return normalizedModel;
}
}

View File

@@ -32,7 +32,7 @@ describe('RemoteDataBuildService', () => {
let service: RemoteDataBuildService; let service: RemoteDataBuildService;
beforeEach(() => { beforeEach(() => {
service = new RemoteDataBuildService(undefined, undefined, undefined); service = new RemoteDataBuildService(undefined, undefined);
}); });
describe('when toPaginatedList is called', () => { describe('when toPaginatedList is called', () => {

View File

@@ -5,7 +5,15 @@ import {
race as observableRace race as observableRace
} from 'rxjs'; } from 'rxjs';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { distinctUntilChanged, flatMap, map, startWith } from 'rxjs/operators'; import {
distinctUntilChanged,
first,
flatMap,
map,
startWith,
switchMap,
take
} from 'rxjs/operators';
import { hasValue, hasValueOperator, isEmpty, isNotEmpty } from '../../../shared/empty.util'; import { hasValue, hasValueOperator, isEmpty, isNotEmpty } from '../../../shared/empty.util';
import { PaginatedList } from '../../data/paginated-list'; import { PaginatedList } from '../../data/paginated-list';
import { RemoteData } from '../../data/remote-data'; import { RemoteData } from '../../data/remote-data';
@@ -16,22 +24,18 @@ 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.models';
import { ResponseCacheEntry } from '../response-cache.reducer';
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 { PageInfo } from '../../shared/page-info.model';
import { import {
filterSuccessfulResponses, filterSuccessfulResponses,
getRequestFromSelflink, getRequestFromRequestHref, getRequestFromRequestUUID,
getResourceLinksFromResponse, getResourceLinksFromResponse
getResponseFromSelflink
} from '../../shared/operators'; } from '../../shared/operators';
@Injectable() @Injectable()
export class RemoteDataBuildService { export class RemoteDataBuildService {
constructor(protected objectCache: ObjectCacheService, constructor(protected objectCache: ObjectCacheService,
protected responseCache: ResponseCacheService,
protected requestService: RequestService) { protected requestService: RequestService) {
} }
@@ -39,29 +43,24 @@ export class RemoteDataBuildService {
if (typeof href$ === 'string') { if (typeof href$ === 'string') {
href$ = observableOf(href$); href$ = observableOf(href$);
} }
const requestHref$ = href$.pipe(flatMap((href: string) => const requestUUID$ = href$.pipe(
this.objectCache.getRequestHrefBySelfLink(href))); switchMap((href: string) =>
this.objectCache.getRequestUUIDBySelfLink(href)),
);
const requestEntry$ = observableRace( const requestEntry$ = observableRace(
href$.pipe(getRequestFromSelflink(this.requestService)), href$.pipe(getRequestFromRequestHref(this.requestService)),
requestHref$.pipe(getRequestFromSelflink(this.requestService)) requestUUID$.pipe(getRequestFromRequestUUID(this.requestService)),
); );
const responseCache$ = observableRace(
href$.pipe(getResponseFromSelflink(this.responseCache)),
requestHref$.pipe(getResponseFromSelflink(this.responseCache))
);
// always use self link if that is cached, only if it isn't, get it via the response. // always use self link if that is cached, only if it isn't, get it via the response.
const payload$ = const payload$ =
observableCombineLatest( observableCombineLatest(
href$.pipe( href$.pipe(
flatMap((href: string) => this.objectCache.getBySelfLink<TNormalized>(href)), switchMap((href: string) => this.objectCache.getBySelfLink<TNormalized>(href)),
startWith(undefined) startWith(undefined)),
), requestEntry$.pipe(
responseCache$.pipe(
getResourceLinksFromResponse(), getResourceLinksFromResponse(),
flatMap((resourceSelfLinks: string[]) => { switchMap((resourceSelfLinks: string[]) => {
if (isNotEmpty(resourceSelfLinks)) { if (isNotEmpty(resourceSelfLinks)) {
return this.objectCache.getBySelfLink(resourceSelfLinks[0]); return this.objectCache.getBySelfLink(resourceSelfLinks[0]);
} else { } else {
@@ -86,21 +85,21 @@ export class RemoteDataBuildService {
startWith(undefined), startWith(undefined),
distinctUntilChanged() distinctUntilChanged()
); );
return this.toRemoteDataObservable(requestEntry$, responseCache$, payload$); return this.toRemoteDataObservable(requestEntry$, payload$);
} }
toRemoteDataObservable<T>(requestEntry$: Observable<RequestEntry>, responseCache$: Observable<ResponseCacheEntry>, payload$: Observable<T>) { toRemoteDataObservable<T>(requestEntry$: Observable<RequestEntry>, payload$: Observable<T>) {
return observableCombineLatest(requestEntry$, responseCache$.pipe(startWith(undefined)), payload$).pipe( return observableCombineLatest(requestEntry$, payload$).pipe(
map(([reqEntry, resEntry, payload]) => { map(([reqEntry, payload]) => {
const requestPending = hasValue(reqEntry.requestPending) ? reqEntry.requestPending : true; const requestPending = hasValue(reqEntry.requestPending) ? reqEntry.requestPending : true;
const responsePending = hasValue(reqEntry.responsePending) ? reqEntry.responsePending : false; const responsePending = hasValue(reqEntry.responsePending) ? reqEntry.responsePending : false;
let isSuccessful: boolean; let isSuccessful: boolean;
let error: RemoteDataError; let error: RemoteDataError;
if (hasValue(resEntry) && hasValue(resEntry.response)) { if (hasValue(reqEntry) && hasValue(reqEntry.response)) {
isSuccessful = resEntry.response.isSuccessful; isSuccessful = reqEntry.response.isSuccessful;
const errorMessage = isSuccessful === false ? (resEntry.response as ErrorResponse).errorMessage : undefined; const errorMessage = isSuccessful === false ? (reqEntry.response as ErrorResponse).errorMessage : undefined;
if (hasValue(errorMessage)) { if (hasValue(errorMessage)) {
error = new RemoteDataError(resEntry.response.statusCode, errorMessage); error = new RemoteDataError(reqEntry.response.statusCode, errorMessage);
} }
} }
return new RemoteData( return new RemoteData(
@@ -119,10 +118,8 @@ export class RemoteDataBuildService {
href$ = observableOf(href$); href$ = observableOf(href$);
} }
const requestEntry$ = href$.pipe(getRequestFromSelflink(this.requestService)); const requestEntry$ = href$.pipe(getRequestFromRequestHref(this.requestService));
const responseCache$ = href$.pipe(getResponseFromSelflink(this.responseCache)); const tDomainList$ = requestEntry$.pipe(
const tDomainList$ = responseCache$.pipe(
getResourceLinksFromResponse(), getResourceLinksFromResponse(),
flatMap((resourceUUIDs: string[]) => { flatMap((resourceUUIDs: string[]) => {
return this.objectCache.getList(resourceUUIDs).pipe( return this.objectCache.getList(resourceUUIDs).pipe(
@@ -133,14 +130,13 @@ export class RemoteDataBuildService {
})); }));
}), }),
startWith([]), startWith([]),
distinctUntilChanged() distinctUntilChanged(),
); );
const pageInfo$ = requestEntry$.pipe(
const pageInfo$ = responseCache$.pipe(
filterSuccessfulResponses(), filterSuccessfulResponses(),
map((entry: ResponseCacheEntry) => { map((response: DSOSuccessResponse) => {
if (hasValue((entry.response as DSOSuccessResponse).pageInfo)) { if (hasValue((response as DSOSuccessResponse).pageInfo)) {
const resPageInfo = (entry.response as DSOSuccessResponse).pageInfo; const resPageInfo = (response as DSOSuccessResponse).pageInfo;
if (isNotEmpty(resPageInfo) && resPageInfo.currentPage >= 0) { if (isNotEmpty(resPageInfo) && resPageInfo.currentPage >= 0) {
return Object.assign({}, resPageInfo, { currentPage: resPageInfo.currentPage + 1 }); return Object.assign({}, resPageInfo, { currentPage: resPageInfo.currentPage + 1 });
} else { } else {
@@ -156,12 +152,11 @@ export class RemoteDataBuildService {
}) })
); );
return this.toRemoteDataObservable(requestEntry$, responseCache$, payload$); return this.toRemoteDataObservable(requestEntry$, payload$);
} }
build<TNormalized, TDomain>(normalized: TNormalized): TDomain { build<TNormalized, TDomain>(normalized: TNormalized): TDomain {
const links: any = {}; const links: any = {};
const relationships = getRelationships(normalized.constructor) || []; const relationships = getRelationships(normalized.constructor) || [];
relationships.forEach((relationship: string) => { relationships.forEach((relationship: string) => {
@@ -204,7 +199,6 @@ export class RemoteDataBuildService {
} }
} }
}); });
const domainModel = getMapsTo(normalized.constructor); const domainModel = getMapsTo(normalized.constructor);
return Object.assign(new domainModel(), normalized, links); return Object.assign(new domainModel(), normalized, links);
} }

View File

@@ -1,4 +1,4 @@
import { autoserialize, inheritSerialization } from 'cerialize'; import { autoserialize, deserialize, inheritSerialization, serialize } from 'cerialize';
import { NormalizedDSpaceObject } from './normalized-dspace-object.model'; import { NormalizedDSpaceObject } from './normalized-dspace-object.model';
import { Community } from '../../shared/community.model'; import { Community } from '../../shared/community.model';
@@ -21,32 +21,32 @@ export class NormalizedCommunity extends NormalizedDSpaceObject {
/** /**
* The Bitstream that represents the logo of this Community * The Bitstream that represents the logo of this Community
*/ */
@autoserialize @deserialize
@relationship(ResourceType.Bitstream, false) @relationship(ResourceType.Bitstream, false)
logo: string; logo: string;
/** /**
* An array of Communities that are direct parents of this Community * An array of Communities that are direct parents of this Community
*/ */
@autoserialize @deserialize
@relationship(ResourceType.Community, true) @relationship(ResourceType.Community, true)
parents: string[]; parents: string[];
/** /**
* The Community that owns this Community * The Community that owns this Community
*/ */
@autoserialize @deserialize
@relationship(ResourceType.Community, false) @relationship(ResourceType.Community, false)
owner: string; owner: string;
/** /**
* List of Collections that are owned by this Community * List of Collections that are owned by this Community
*/ */
@autoserialize @deserialize
@relationship(ResourceType.Collection, true) @relationship(ResourceType.Collection, true)
collections: string[]; collections: string[];
@autoserialize @deserialize
@relationship(ResourceType.Community, true) @relationship(ResourceType.Community, true)
subcommunities: string[]; subcommunities: string[];

View File

@@ -1,4 +1,4 @@
import { autoserialize, autoserializeAs } from 'cerialize'; import { autoserialize, autoserializeAs, deserialize, serialize } from 'cerialize';
import { DSpaceObject } from '../../shared/dspace-object.model'; import { DSpaceObject } from '../../shared/dspace-object.model';
import { Metadatum } from '../../shared/metadatum.model'; import { Metadatum } from '../../shared/metadatum.model';
@@ -18,7 +18,7 @@ export class NormalizedDSpaceObject extends NormalizedObject {
* Repeated here to make the serialization work, * Repeated here to make the serialization work,
* inheritSerialization doesn't seem to work for more than one level * inheritSerialization doesn't seem to work for more than one level
*/ */
@autoserialize @deserialize
self: string; self: string;
/** /**
@@ -45,12 +45,6 @@ export class NormalizedDSpaceObject extends NormalizedObject {
@autoserialize @autoserialize
type: ResourceType; type: ResourceType;
/**
* The name for this DSpaceObject
*/
@autoserialize
name: string;
/** /**
* An array containing all metadata of this DSpaceObject * An array containing all metadata of this DSpaceObject
*/ */
@@ -60,13 +54,13 @@ export class NormalizedDSpaceObject extends NormalizedObject {
/** /**
* An array of DSpaceObjects that are direct parents of this DSpaceObject * An array of DSpaceObjects that are direct parents of this DSpaceObject
*/ */
@autoserialize @deserialize
parents: string[]; parents: string[];
/** /**
* The DSpaceObject that owns this DSpaceObject * The DSpaceObject that owns this DSpaceObject
*/ */
@autoserialize @deserialize
owner: string; owner: string;
/** /**
@@ -75,7 +69,7 @@ export class NormalizedDSpaceObject extends NormalizedObject {
* Repeated here to make the serialization work, * Repeated here to make the serialization work,
* inheritSerialization doesn't seem to work for more than one level * inheritSerialization doesn't seem to work for more than one level
*/ */
@autoserialize @deserialize
_links: { _links: {
[name: string]: string [name: string]: string
} }

View File

@@ -2,6 +2,7 @@ import { Action } from '@ngrx/store';
import { type } from '../../shared/ngrx/type'; import { type } from '../../shared/ngrx/type';
import { CacheableObject } from './object-cache.reducer'; import { CacheableObject } from './object-cache.reducer';
import { Operation } from 'fast-json-patch';
/** /**
* The list of ObjectCacheAction type definitions * The list of ObjectCacheAction type definitions
@@ -9,7 +10,9 @@ import { CacheableObject } from './object-cache.reducer';
export const ObjectCacheActionTypes = { export const ObjectCacheActionTypes = {
ADD: type('dspace/core/cache/object/ADD'), ADD: type('dspace/core/cache/object/ADD'),
REMOVE: type('dspace/core/cache/object/REMOVE'), REMOVE: type('dspace/core/cache/object/REMOVE'),
RESET_TIMESTAMPS: type('dspace/core/cache/object/RESET_TIMESTAMPS') RESET_TIMESTAMPS: type('dspace/core/cache/object/RESET_TIMESTAMPS'),
ADD_PATCH: type('dspace/core/cache/object/ADD_PATCH'),
APPLY_PATCH: type('dspace/core/cache/object/APPLY_PATCH')
}; };
/* tslint:disable:max-classes-per-file */ /* tslint:disable:max-classes-per-file */
@@ -22,7 +25,7 @@ export class AddToObjectCacheAction implements Action {
objectToCache: CacheableObject; objectToCache: CacheableObject;
timeAdded: number; timeAdded: number;
msToLive: number; msToLive: number;
requestHref: string; requestUUID: string;
}; };
/** /**
@@ -39,8 +42,8 @@ export class AddToObjectCacheAction implements Action {
* This isn't necessarily the same as the object's self * This isn't necessarily the same as the object's self
* link, it could have been part of a list for example * link, it could have been part of a list for example
*/ */
constructor(objectToCache: CacheableObject, timeAdded: number, msToLive: number, requestHref: string) { constructor(objectToCache: CacheableObject, timeAdded: number, msToLive: number, requestUUID: string) {
this.payload = { objectToCache, timeAdded, msToLive, requestHref }; this.payload = { objectToCache, timeAdded, msToLive, requestUUID };
} }
} }
@@ -54,11 +57,11 @@ export class RemoveFromObjectCacheAction implements Action {
/** /**
* Create a new RemoveFromObjectCacheAction * Create a new RemoveFromObjectCacheAction
* *
* @param uuid * @param href
* the UUID of the object to remove * the unique href of the object to remove
*/ */
constructor(uuid: string) { constructor(href: string) {
this.payload = uuid; this.payload = href;
} }
} }
@@ -79,6 +82,48 @@ export class ResetObjectCacheTimestampsAction implements Action {
this.payload = newTimestamp; this.payload = newTimestamp;
} }
} }
/**
* An ngrx action to add new operations to a specified cached object
*/
export class AddPatchObjectCacheAction implements Action {
type = ObjectCacheActionTypes.ADD_PATCH;
payload: {
href: string,
operations: Operation[]
};
/**
* Create a new AddPatchObjectCacheAction
*
* @param href
* the unique href of the object that should be updated
* @param operations
* the list of operations to add
*/
constructor(href: string, operations: Operation[]) {
this.payload = { href, operations };
}
}
/**
* An ngrx action to apply all existing operations to a specified cached object
*/
export class ApplyPatchObjectCacheAction implements Action {
type = ObjectCacheActionTypes.APPLY_PATCH;
payload: string;
/**
* Create a new ApplyPatchObjectCacheAction
*
* @param href
* the unique href of the object that should be updated
*/
constructor(href: string) {
this.payload = href;
}
}
/* tslint:enable:max-classes-per-file */ /* tslint:enable:max-classes-per-file */
/** /**
@@ -87,4 +132,6 @@ export class ResetObjectCacheTimestampsAction implements Action {
export type ObjectCacheAction export type ObjectCacheAction
= AddToObjectCacheAction = AddToObjectCacheAction
| RemoveFromObjectCacheAction | RemoveFromObjectCacheAction
| ResetObjectCacheTimestampsAction; | ResetObjectCacheTimestampsAction
| AddPatchObjectCacheAction
| ApplyPatchObjectCacheAction;

View File

@@ -2,9 +2,13 @@ import * as deepFreeze from 'deep-freeze';
import { objectCacheReducer } from './object-cache.reducer'; import { objectCacheReducer } from './object-cache.reducer';
import { import {
AddPatchObjectCacheAction,
AddToObjectCacheAction, AddToObjectCacheAction,
RemoveFromObjectCacheAction, ResetObjectCacheTimestampsAction ApplyPatchObjectCacheAction,
RemoveFromObjectCacheAction,
ResetObjectCacheTimestampsAction
} from './object-cache.actions'; } from './object-cache.actions';
import { Operation } from 'fast-json-patch';
class NullAction extends RemoveFromObjectCacheAction { class NullAction extends RemoveFromObjectCacheAction {
type = null; type = null;
@@ -16,8 +20,11 @@ class NullAction extends RemoveFromObjectCacheAction {
} }
describe('objectCacheReducer', () => { describe('objectCacheReducer', () => {
const requestUUID1 = '8646169a-a8fc-4b31-a368-384c07867eb1';
const requestUUID2 = 'bd36820b-4bf7-4d58-bd80-b832058b7279';
const selfLink1 = 'https://localhost:8080/api/core/items/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; const selfLink1 = 'https://localhost:8080/api/core/items/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7';
const selfLink2 = 'https://localhost:8080/api/core/items/28b04544-1766-4e82-9728-c4e93544ecd3'; const selfLink2 = 'https://localhost:8080/api/core/items/28b04544-1766-4e82-9728-c4e93544ecd3';
const newName = 'new different name';
const testState = { const testState = {
[selfLink1]: { [selfLink1]: {
data: { data: {
@@ -26,16 +33,20 @@ describe('objectCacheReducer', () => {
}, },
timeAdded: new Date().getTime(), timeAdded: new Date().getTime(),
msToLive: 900000, msToLive: 900000,
requestHref: selfLink1 requestUUID: requestUUID1,
patches: [],
isDirty: false
}, },
[selfLink2]: { [selfLink2]: {
data: { data: {
self: selfLink2, self: requestUUID2,
foo: 'baz' foo: 'baz'
}, },
timeAdded: new Date().getTime(), timeAdded: new Date().getTime(),
msToLive: 900000, msToLive: 900000,
requestHref: selfLink2 requestUUID: selfLink2,
patches: [],
isDirty: false
} }
}; };
deepFreeze(testState); deepFreeze(testState);
@@ -59,8 +70,8 @@ describe('objectCacheReducer', () => {
const objectToCache = { self: selfLink1 }; const objectToCache = { self: selfLink1 };
const timeAdded = new Date().getTime(); const timeAdded = new Date().getTime();
const msToLive = 900000; const msToLive = 900000;
const requestHref = 'https://rest.api/endpoint/selfLink1'; const requestUUID = requestUUID1;
const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive, requestHref); const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive, requestUUID);
const newState = objectCacheReducer(state, action); const newState = objectCacheReducer(state, action);
expect(newState[selfLink1].data).toEqual(objectToCache); expect(newState[selfLink1].data).toEqual(objectToCache);
@@ -72,8 +83,8 @@ describe('objectCacheReducer', () => {
const objectToCache = { self: selfLink1, foo: 'baz', somethingElse: true }; const objectToCache = { self: selfLink1, foo: 'baz', somethingElse: true };
const timeAdded = new Date().getTime(); const timeAdded = new Date().getTime();
const msToLive = 900000; const msToLive = 900000;
const requestHref = 'https://rest.api/endpoint/selfLink1'; const requestUUID = requestUUID1;
const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive, requestHref); const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive, requestUUID);
const newState = objectCacheReducer(testState, action); const newState = objectCacheReducer(testState, action);
/* tslint:disable:no-string-literal */ /* tslint:disable:no-string-literal */
@@ -87,8 +98,8 @@ describe('objectCacheReducer', () => {
const objectToCache = { self: selfLink1 }; const objectToCache = { self: selfLink1 };
const timeAdded = new Date().getTime(); const timeAdded = new Date().getTime();
const msToLive = 900000; const msToLive = 900000;
const requestHref = 'https://rest.api/endpoint/selfLink1'; const requestUUID = requestUUID1;
const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive, requestHref); const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive, requestUUID);
deepFreeze(state); deepFreeze(state);
objectCacheReducer(state, action); objectCacheReducer(state, action);
@@ -132,4 +143,32 @@ describe('objectCacheReducer', () => {
objectCacheReducer(testState, action); objectCacheReducer(testState, action);
}); });
it('should perform the ADD_PATCH action without affecting the previous state', () => {
const action = new AddPatchObjectCacheAction(selfLink1, [{
op: 'replace',
path: '/name',
value: 'random string'
}]);
// testState has already been frozen above
objectCacheReducer(testState, action);
});
it('should when the ADD_PATCH action dispatched', () => {
const patch = [{ op: 'add', path: '/name', value: newName } as Operation];
const action = new AddPatchObjectCacheAction(selfLink1, patch);
const newState = objectCacheReducer(testState, action);
expect(newState[selfLink1].patches.map((p) => p.operations)).toContain(patch);
});
it('should when the APPLY_PATCH action dispatched', () => {
const patch = [{ op: 'add', path: '/name', value: newName } as Operation];
const addPatchAction = new AddPatchObjectCacheAction(selfLink1, patch);
const stateWithPatch = objectCacheReducer(testState, addPatchAction);
const action = new ApplyPatchObjectCacheAction(selfLink1);
const newState = objectCacheReducer(stateWithPatch, action);
expect(newState[selfLink1].patches).toEqual([]);
expect((newState[selfLink1].data as any).name).toEqual(newName);
});
}); });

View File

@@ -1,10 +1,15 @@
import { import {
ObjectCacheAction, ObjectCacheActionTypes, AddToObjectCacheAction, ObjectCacheAction,
RemoveFromObjectCacheAction, ResetObjectCacheTimestampsAction ObjectCacheActionTypes,
AddToObjectCacheAction,
RemoveFromObjectCacheAction,
ResetObjectCacheTimestampsAction,
AddPatchObjectCacheAction, ApplyPatchObjectCacheAction
} from './object-cache.actions'; } from './object-cache.actions';
import { hasValue } from '../../shared/empty.util'; import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { CacheEntry } from './cache-entry'; import { CacheEntry } from './cache-entry';
import { ResourceType } from '../shared/resource-type'; import { ResourceType } from '../shared/resource-type';
import { applyPatch, Operation } from 'fast-json-patch';
export enum DirtyType { export enum DirtyType {
Created = 'Created', Created = 'Created',
@@ -12,6 +17,21 @@ export enum DirtyType {
Deleted = 'Deleted' Deleted = 'Deleted'
} }
/**
* An interface to represent a JsonPatch
*/
export interface Patch {
/**
* The identifier for this Patch
*/
uuid?: string;
/**
* the list of operations this Patch is composed of
*/
operations: Operation[];
}
/** /**
* An interface to represent objects that can be cached * An interface to represent objects that can be cached
* *
@@ -35,7 +55,9 @@ export class ObjectCacheEntry implements CacheEntry {
data: CacheableObject; data: CacheableObject;
timeAdded: number; timeAdded: number;
msToLive: number; msToLive: number;
requestHref: string; requestUUID: string;
patches: Patch[] = [];
isDirty: boolean;
} }
/** /**
@@ -76,6 +98,14 @@ export function objectCacheReducer(state = initialState, action: ObjectCacheActi
return resetObjectCacheTimestamps(state, action as ResetObjectCacheTimestampsAction) return resetObjectCacheTimestamps(state, action as ResetObjectCacheTimestampsAction)
} }
case ObjectCacheActionTypes.ADD_PATCH: {
return addPatchObjectCache(state, action as AddPatchObjectCacheAction);
}
case ObjectCacheActionTypes.APPLY_PATCH: {
return applyPatchObjectCache(state, action as ApplyPatchObjectCacheAction);
}
default: { default: {
return state; return state;
} }
@@ -93,12 +123,15 @@ export function objectCacheReducer(state = initialState, action: ObjectCacheActi
* the new state, with the object added, or overwritten. * the new state, with the object added, or overwritten.
*/ */
function addToObjectCache(state: ObjectCacheState, action: AddToObjectCacheAction): ObjectCacheState { function addToObjectCache(state: ObjectCacheState, action: AddToObjectCacheAction): ObjectCacheState {
const existing = state[action.payload.objectToCache.self];
return Object.assign({}, state, { return Object.assign({}, state, {
[action.payload.objectToCache.self]: { [action.payload.objectToCache.self]: {
data: action.payload.objectToCache, data: action.payload.objectToCache,
timeAdded: action.payload.timeAdded, timeAdded: action.payload.timeAdded,
msToLive: action.payload.msToLive, msToLive: action.payload.msToLive,
requestHref: action.payload.requestHref requestUUID: action.payload.requestUUID,
isDirty: (hasValue(existing) ? isNotEmpty(existing.patches) : false),
patches: (hasValue(existing) ? existing.patches : [])
} }
}); });
} }
@@ -143,3 +176,49 @@ function resetObjectCacheTimestamps(state: ObjectCacheState, action: ResetObject
}); });
return newState; return newState;
} }
/**
* Add the list of patch operations to a cached object
*
* @param state
* the current state
* @param action
* an AddPatchObjectCacheAction
* @return ObjectCacheState
* the new state, with the new operations added to the state of the specified ObjectCacheEntry
*/
function addPatchObjectCache(state: ObjectCacheState, action: AddPatchObjectCacheAction): ObjectCacheState {
const uuid = action.payload.href;
const operations = action.payload.operations;
const newState = Object.assign({}, state);
if (hasValue(newState[uuid])) {
const patches = newState[uuid].patches;
newState[uuid] = Object.assign({}, newState[uuid], {
patches: [...patches, { operations } as Patch],
isDirty: true
});
}
return newState;
}
/**
* Apply the list of patch operations to a cached object
*
* @param state
* the current state
* @param action
* an ApplyPatchObjectCacheAction
* @return ObjectCacheState
* the new state, with the new operations applied to the state of the specified ObjectCacheEntry
*/
function applyPatchObjectCache(state: ObjectCacheState, action: ApplyPatchObjectCacheAction): ObjectCacheState {
const uuid = action.payload;
const newState = Object.assign({}, state);
if (hasValue(newState[uuid])) {
// flatten two dimensional array
const flatPatch: Operation[] = [].concat(...newState[uuid].patches.map((patch) => patch.operations));
const newData = applyPatch(newState[uuid].data, flatPatch, undefined, false);
newState[uuid] = Object.assign({}, newState[uuid], { data: newData.newDocument, patches: [] });
}
return newState;
}

View File

@@ -2,32 +2,52 @@ import { Store } from '@ngrx/store';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { ObjectCacheService } from './object-cache.service'; import { ObjectCacheService } from './object-cache.service';
import { AddToObjectCacheAction, RemoveFromObjectCacheAction } from './object-cache.actions'; import {
AddPatchObjectCacheAction,
AddToObjectCacheAction, ApplyPatchObjectCacheAction,
RemoveFromObjectCacheAction
} from './object-cache.actions';
import { CoreState } from '../core.reducers'; import { CoreState } from '../core.reducers';
import { ResourceType } from '../shared/resource-type'; import { ResourceType } from '../shared/resource-type';
import { NormalizedItem } from './models/normalized-item.model'; import { NormalizedItem } from './models/normalized-item.model';
import { first } from 'rxjs/operators'; import { first } from 'rxjs/operators';
import * as ngrx from '@ngrx/store'; import * as ngrx from '@ngrx/store';
import { Operation } from '../../../../node_modules/fast-json-patch';
import { RestRequestMethod } from '../data/rest-request-method';
import { AddToSSBAction } from './server-sync-buffer.actions';
import { Patch } from './object-cache.reducer';
describe('ObjectCacheService', () => { describe('ObjectCacheService', () => {
let service: ObjectCacheService; let service: ObjectCacheService;
let store: Store<CoreState>; let store: Store<CoreState>;
const selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; const selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7';
const requestUUID = '4d3a4ce8-a375-4b98-859b-39f0a014d736';
const timestamp = new Date().getTime(); const timestamp = new Date().getTime();
const msToLive = 900000; const msToLive = 900000;
const objectToCache = { let objectToCache = {
self: selfLink, self: selfLink,
type: ResourceType.Item type: ResourceType.Item
}; };
const cacheEntry = { let cacheEntry;
data: objectToCache, let invalidCacheEntry;
timeAdded: timestamp, const operations = [{ op: 'replace', path: '/name', value: 'random string' } as Operation];
msToLive: msToLive
}; function init() {
const invalidCacheEntry = Object.assign({}, cacheEntry, { msToLive: -1 }); objectToCache = {
self: selfLink,
type: ResourceType.Item
};
cacheEntry = {
data: objectToCache,
timeAdded: timestamp,
msToLive: msToLive
};
invalidCacheEntry = Object.assign({}, cacheEntry, { msToLive: -1 })
}
beforeEach(() => { beforeEach(() => {
init();
store = new Store<CoreState>(undefined, undefined, undefined); store = new Store<CoreState>(undefined, undefined, undefined);
spyOn(store, 'dispatch'); spyOn(store, 'dispatch');
service = new ObjectCacheService(store); service = new ObjectCacheService(store);
@@ -39,8 +59,8 @@ describe('ObjectCacheService', () => {
describe('add', () => { describe('add', () => {
it('should dispatch an ADD action with the object to add, the time to live, and the current timestamp', () => { it('should dispatch an ADD action with the object to add, the time to live, and the current timestamp', () => {
service.add(objectToCache, msToLive, selfLink); service.add(objectToCache, msToLive, requestUUID);
expect(store.dispatch).toHaveBeenCalledWith(new AddToObjectCacheAction(objectToCache, timestamp, msToLive, selfLink)); expect(store.dispatch).toHaveBeenCalledWith(new AddToObjectCacheAction(objectToCache, timestamp, msToLive, requestUUID));
}); });
}); });
@@ -127,4 +147,30 @@ describe('ObjectCacheService', () => {
}); });
}); });
describe('patch methods', () => {
it('should dispatch the correct actions when addPatch is called', () => {
service.addPatch(selfLink, operations);
expect(store.dispatch).toHaveBeenCalledWith(new AddPatchObjectCacheAction(selfLink, operations));
expect(store.dispatch).toHaveBeenCalledWith(new AddToSSBAction(selfLink, RestRequestMethod.PATCH));
});
it('isDirty should return true when the patches list in the cache entry is not empty', () => {
cacheEntry.patches = [
{
operations: operations
} as Patch];
const result = (service as any).isDirty(cacheEntry);
expect(result).toBe(true);
});
it('isDirty should return false when the patches list in the cache entry is empty', () => {
cacheEntry.patches = [];
const result = (service as any).isDirty(cacheEntry);
expect(result).toBe(false);
});
it('should dispatch the correct actions when applyPatchesToCachedObject is called', () => {
(service as any).applyPatchesToCachedObject(selfLink);
expect(store.dispatch).toHaveBeenCalledWith(new ApplyPatchObjectCacheAction(selfLink));
});
});
}); });

View File

@@ -1,25 +1,33 @@
import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { distinctUntilChanged, filter, first, map, mergeMap, take } from 'rxjs/operators'; import { distinctUntilChanged, filter, map, mergeMap, take, } from 'rxjs/operators';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { MemoizedSelector, select, Store } from '@ngrx/store'; import { MemoizedSelector, select, Store } from '@ngrx/store';
import { IndexName } from '../index/index.reducer'; import { IndexName } from '../index/index.reducer';
import { CacheableObject, ObjectCacheEntry } from './object-cache.reducer'; import { CacheableObject, ObjectCacheEntry } from './object-cache.reducer';
import { AddToObjectCacheAction, RemoveFromObjectCacheAction } from './object-cache.actions'; import {
import { hasNoValue } from '../../shared/empty.util'; AddPatchObjectCacheAction,
AddToObjectCacheAction,
ApplyPatchObjectCacheAction,
RemoveFromObjectCacheAction
} from './object-cache.actions';
import { hasNoValue, isNotEmpty } from '../../shared/empty.util';
import { GenericConstructor } from '../shared/generic-constructor'; import { GenericConstructor } from '../shared/generic-constructor';
import { coreSelector, CoreState } from '../core.reducers'; import { coreSelector, CoreState } from '../core.reducers';
import { pathSelector } from '../shared/selectors'; import { pathSelector } from '../shared/selectors';
import { NormalizedObjectFactory } from './models/normalized-object-factory'; import { NormalizedObjectFactory } from './models/normalized-object-factory';
import { NormalizedObject } from './models/normalized-object.model'; import { NormalizedObject } from './models/normalized-object.model';
import { applyPatch, Operation } from 'fast-json-patch';
import { AddToSSBAction } from './server-sync-buffer.actions';
import { RestRequestMethod } from '../data/rest-request-method';
function selfLinkFromUuidSelector(uuid: string): MemoizedSelector<CoreState, string> { function selfLinkFromUuidSelector(uuid: string): MemoizedSelector<CoreState, string> {
return pathSelector<CoreState, string>(coreSelector, 'index', IndexName.OBJECT, uuid); return pathSelector<CoreState, string>(coreSelector, 'index', IndexName.OBJECT, uuid);
} }
function entryFromSelfLinkSelector(selfLink: string): MemoizedSelector<CoreState, ObjectCacheEntry> { function entryFromSelfLinkSelector(selfLink: string): MemoizedSelector<CoreState, ObjectCacheEntry> {
return pathSelector<CoreState, ObjectCacheEntry>(coreSelector, 'data/object', selfLink); return pathSelector<CoreState, ObjectCacheEntry>(coreSelector, 'cache/object', selfLink);
} }
/** /**
@@ -37,20 +45,18 @@ export class ObjectCacheService {
* The object to add * The object to add
* @param msToLive * @param msToLive
* The number of milliseconds it should be cached for * The number of milliseconds it should be cached for
* @param requestHref * @param requestUUID
* The selfLink of the request that resulted in this object * The UUID of the request that resulted in this object
* This isn't necessarily the same as the object's self
* link, it could have been part of a list for example
*/ */
add(objectToCache: CacheableObject, msToLive: number, requestHref: string): void { add(objectToCache: CacheableObject, msToLive: number, requestUUID: string): void {
this.store.dispatch(new AddToObjectCacheAction(objectToCache, new Date().getTime(), msToLive, requestHref)); this.store.dispatch(new AddToObjectCacheAction(objectToCache, new Date().getTime(), msToLive, requestUUID));
} }
/** /**
* Remove the object with the supplied UUID from the cache * Remove the object with the supplied href from the cache
* *
* @param uuid * @param href
* The UUID of the object to be removed * The unique href of the object to be removed
*/ */
remove(uuid: string): void { remove(uuid: string): void {
this.store.dispatch(new RemoveFromObjectCacheAction(uuid)); this.store.dispatch(new RemoveFromObjectCacheAction(uuid));
@@ -82,10 +88,21 @@ export class ObjectCacheService {
getBySelfLink<T extends NormalizedObject>(selfLink: string): Observable<T> { getBySelfLink<T extends NormalizedObject>(selfLink: string): Observable<T> {
return this.getEntry(selfLink).pipe( return this.getEntry(selfLink).pipe(
map((entry: ObjectCacheEntry) => {
if (isNotEmpty(entry.patches)) {
const flatPatch: Operation[] = [].concat(...entry.patches.map((patch) => patch.operations));
const patchedData = applyPatch(entry.data, flatPatch, undefined, false).newDocument;
return Object.assign({}, entry, { data: patchedData });
} else {
return entry;
}
}
),
map((entry: ObjectCacheEntry) => { map((entry: ObjectCacheEntry) => {
const type: GenericConstructor<NormalizedObject> = NormalizedObjectFactory.getConstructor(entry.data.type); const type: GenericConstructor<NormalizedObject> = NormalizedObjectFactory.getConstructor(entry.data.type);
return Object.assign(new type(), entry.data) as T return Object.assign(new type(), entry.data) as T
})); })
);
} }
private getEntry(selfLink: string): Observable<ObjectCacheEntry> { private getEntry(selfLink: string): Observable<ObjectCacheEntry> {
@@ -96,16 +113,16 @@ export class ObjectCacheService {
); );
} }
getRequestHrefBySelfLink(selfLink: string): Observable<string> { getRequestUUIDBySelfLink(selfLink: string): Observable<string> {
return this.getEntry(selfLink).pipe( return this.getEntry(selfLink).pipe(
map((entry: ObjectCacheEntry) => entry.requestHref), map((entry: ObjectCacheEntry) => entry.requestUUID),
distinctUntilChanged(),); distinctUntilChanged());
} }
getRequestHrefByUUID(uuid: string): Observable<string> { getRequestUUIDByObjectUUID(uuid: string): Observable<string> {
return this.store.pipe( return this.store.pipe(
select(selfLinkFromUuidSelector(uuid)), select(selfLinkFromUuidSelector(uuid)),
mergeMap((selfLink: string) => this.getRequestHrefBySelfLink(selfLink)) mergeMap((selfLink: string) => this.getRequestUUIDBySelfLink(selfLink))
); );
} }
@@ -148,7 +165,7 @@ export class ObjectCacheService {
this.store.pipe( this.store.pipe(
select(selfLinkFromUuidSelector(uuid)), select(selfLinkFromUuidSelector(uuid)),
first() take(1)
).subscribe((selfLink: string) => result = this.hasBySelfLink(selfLink)); ).subscribe((selfLink: string) => result = this.hasBySelfLink(selfLink));
return result; return result;
@@ -167,7 +184,7 @@ export class ObjectCacheService {
let result = false; let result = false;
this.store.pipe(select(entryFromSelfLinkSelector(selfLink)), this.store.pipe(select(entryFromSelfLinkSelector(selfLink)),
first() take(1)
).subscribe((entry: ObjectCacheEntry) => result = this.isValid(entry)); ).subscribe((entry: ObjectCacheEntry) => result = this.isValid(entry));
return result; return result;
@@ -195,4 +212,39 @@ export class ObjectCacheService {
} }
} }
/**
* Add operations to the existing list of operations for an ObjectCacheEntry
* Makes sure the ServerSyncBuffer for this ObjectCacheEntry is updated
* @param {string} uuid
* the uuid of the ObjectCacheEntry
* @param {Operation[]} patch
* list of operations to perform
*/
public addPatch(selfLink: string, patch: Operation[]) {
this.store.dispatch(new AddPatchObjectCacheAction(selfLink, patch));
this.store.dispatch(new AddToSSBAction(selfLink, RestRequestMethod.PATCH));
}
/**
* Check whether there are any unperformed operations for an ObjectCacheEntry
*
* @param entry
* the entry to check
* @return boolean
* false if the entry is there are no operations left in the ObjectCacheEntry, true otherwise
*/
private isDirty(entry: ObjectCacheEntry): boolean {
return isNotEmpty(entry.patches);
}
/**
* Apply the existing operations on an ObjectCacheEntry in the store
* NB: this does not make any server side changes
* @param {string} uuid
* the uuid of the ObjectCacheEntry
*/
private applyPatchesToCachedObject(selfLink: string) {
this.store.dispatch(new ApplyPatchObjectCacheAction(selfLink));
}
} }

View File

@@ -1,72 +0,0 @@
import { Action } from '@ngrx/store';
import { type } from '../../shared/ngrx/type';
import { RestResponse } from './response-cache.models';
/**
* The list of ResponseCacheAction type definitions
*/
export const ResponseCacheActionTypes = {
ADD: type('dspace/core/cache/response/ADD'),
REMOVE: type('dspace/core/cache/response/REMOVE'),
RESET_TIMESTAMPS: type('dspace/core/cache/response/RESET_TIMESTAMPS')
};
/* tslint:disable:max-classes-per-file */
export class ResponseCacheAddAction implements Action {
type = ResponseCacheActionTypes.ADD;
payload: {
key: string,
response: RestResponse
timeAdded: number;
msToLive: number;
};
constructor(key: string, response: RestResponse, timeAdded: number, msToLive: number) {
this.payload = { key, response, timeAdded, msToLive };
}
}
/**
* An ngrx action to remove a request from the cache
*/
export class ResponseCacheRemoveAction implements Action {
type = ResponseCacheActionTypes.REMOVE;
payload: string;
/**
* Create a new ResponseCacheRemoveAction
* @param key
* The key of the request to remove
*/
constructor(key: string) {
this.payload = key;
}
}
/**
* An ngrx action to reset the timeAdded property of all cached objects
*/
export class ResetResponseCacheTimestampsAction implements Action {
type = ResponseCacheActionTypes.RESET_TIMESTAMPS;
payload: number;
/**
* Create a new ResetObjectCacheTimestampsAction
*
* @param newTimestamp
* the new timeAdded all objects should get
*/
constructor(newTimestamp: number) {
this.payload = newTimestamp;
}
}
/* tslint:enable:max-classes-per-file */
/**
* A type to encompass all ResponseCacheActions
*/
export type ResponseCacheAction
= ResponseCacheAddAction
| ResponseCacheRemoveAction
| ResetResponseCacheTimestampsAction;

View File

@@ -1,38 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { Observable } from 'rxjs';
import { provideMockActions } from '@ngrx/effects/testing';
import { cold, hot } from 'jasmine-marbles';
import { StoreActionTypes } from '../../store.actions';
import { ResponseCacheEffects } from './response-cache.effects';
import { ResetResponseCacheTimestampsAction } from './response-cache.actions';
describe('ResponseCacheEffects', () => {
let cacheEffects: ResponseCacheEffects;
let actions: Observable<any>;
const timestamp = 10000;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
ResponseCacheEffects,
provideMockActions(() => actions),
// other providers
],
});
cacheEffects = TestBed.get(ResponseCacheEffects);
});
describe('fixTimestampsOnRehydrate$', () => {
it('should return a RESET_TIMESTAMPS action in response to a REHYDRATE action', () => {
spyOn(Date.prototype, 'getTime').and.callFake(() => {
return timestamp;
});
actions = hot('--a-', { a: { type: StoreActionTypes.REHYDRATE, payload: {} } });
const expected = cold('--b-', { b: new ResetResponseCacheTimestampsAction(new Date().getTime()) });
expect(cacheEffects.fixTimestampsOnRehydrate).toBeObservable(expected);
});
});
});

View File

@@ -1,27 +0,0 @@
import { map } from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { Actions, Effect, ofType } from '@ngrx/effects';
import { ResetResponseCacheTimestampsAction } from './response-cache.actions';
import { StoreActionTypes } from '../../store.actions';
@Injectable()
export class ResponseCacheEffects {
/**
* When the store is rehydrated in the browser, set all cache
* timestamps to 'now', because the time zone of the server can
* differ from the client.
*
* This assumes that the server cached everything a negligible
* time ago, and will likely need to be revisited later
*/
@Effect() fixTimestampsOnRehydrate = this.actions$
.pipe(ofType(StoreActionTypes.REHYDRATE),
map(() => new ResetResponseCacheTimestampsAction(new Date().getTime()))
);
constructor(private actions$: Actions,) {
}
}

View File

@@ -1,124 +0,0 @@
import * as deepFreeze from 'deep-freeze';
import { responseCacheReducer, ResponseCacheState } from './response-cache.reducer';
import {
ResponseCacheRemoveAction,
ResetResponseCacheTimestampsAction, ResponseCacheAddAction
} from './response-cache.actions';
import { RestResponse } from './response-cache.models';
class NullAction extends ResponseCacheRemoveAction {
type = null;
payload = null;
constructor() {
super(null);
}
}
describe('responseCacheReducer', () => {
const keys = ['125c17f89046283c5f0640722aac9feb', 'a06c3006a41caec5d635af099b0c780c'];
const msToLive = 900000;
const uuids = [
'9e32a2e2-6b91-4236-a361-995ccdc14c60',
'598ce822-c357-46f3-ab70-63724d02d6ad',
'be8325f7-243b-49f4-8a4b-df2b793ff3b5'
];
const testState: ResponseCacheState = {
[keys[0]]: {
key: keys[0],
response: new RestResponse(true, '200'),
timeAdded: new Date().getTime(),
msToLive: msToLive
},
[keys[1]]: {
key: keys[1],
response: new RestResponse(true, '200'),
timeAdded: new Date().getTime(),
msToLive: msToLive
}
};
deepFreeze(testState);
const errorState: {} = {
[keys[0]]: {
errorMessage: 'error',
resourceUUIDs: uuids
}
};
deepFreeze(errorState);
it('should return the current state when no valid actions have been made', () => {
const action = new NullAction();
const newState = responseCacheReducer(testState, action);
expect(newState).toEqual(testState);
});
it('should start with an empty cache', () => {
const action = new NullAction();
const initialState = responseCacheReducer(undefined, action);
expect(initialState).toEqual(Object.create(null));
});
describe('ADD', () => {
const addTimeAdded = new Date().getTime();
const addMsToLive = 5;
const addResponse = new RestResponse(true, '200');
const action = new ResponseCacheAddAction(keys[0], addResponse, addTimeAdded, addMsToLive);
it('should perform the action without affecting the previous state', () => {
// testState has already been frozen above
responseCacheReducer(testState, action);
});
it('should add the response to the cached request', () => {
const newState = responseCacheReducer(testState, action);
expect(newState[keys[0]].timeAdded).toBe(addTimeAdded);
expect(newState[keys[0]].msToLive).toBe(addMsToLive);
expect(newState[keys[0]].response).toBe(addResponse);
});
});
describe('REMOVE', () => {
it('should perform the action without affecting the previous state', () => {
const action = new ResponseCacheRemoveAction(keys[0]);
// testState has already been frozen above
responseCacheReducer(testState, action);
});
it('should remove the specified request from the cache', () => {
const action = new ResponseCacheRemoveAction(keys[0]);
const newState = responseCacheReducer(testState, action);
expect(testState[keys[0]]).not.toBeUndefined();
expect(newState[keys[0]]).toBeUndefined();
});
it('shouldn\'t do anything when the specified key isn\'t cached', () => {
const wrongKey = 'this isn\'t cached';
const action = new ResponseCacheRemoveAction(wrongKey);
const newState = responseCacheReducer(testState, action);
expect(testState[wrongKey]).toBeUndefined();
expect(newState).toEqual(testState);
});
});
describe('RESET_TIMESTAMPS', () => {
const newTimeStamp = new Date().getTime();
const action = new ResetResponseCacheTimestampsAction(newTimeStamp);
it('should perform the action without affecting the previous state', () => {
// testState has already been frozen above
responseCacheReducer(testState, action);
});
it('should set the timestamp of all requests in the cache', () => {
const newState = responseCacheReducer(testState, action);
Object.keys(newState).forEach((key) => {
expect(newState[key].timeAdded).toEqual(newTimeStamp);
});
});
});
});

View File

@@ -1,111 +0,0 @@
import {
ResponseCacheAction, ResponseCacheActionTypes,
ResponseCacheRemoveAction, ResetResponseCacheTimestampsAction,
ResponseCacheAddAction
} from './response-cache.actions';
import { CacheEntry } from './cache-entry';
import { hasValue } from '../../shared/empty.util';
import { RestResponse } from './response-cache.models';
/**
* An entry in the ResponseCache
*/
export class ResponseCacheEntry implements CacheEntry {
key: string;
response: RestResponse;
timeAdded: number;
msToLive: number;
}
/**
* The ResponseCache State
*/
export interface ResponseCacheState {
[key: string]: ResponseCacheEntry
}
// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`)
const initialState = Object.create(null);
/**
* The ResponseCache Reducer
*
* @param state
* the current state
* @param action
* the action to perform on the state
* @return ResponseCacheState
* the new state
*/
export function responseCacheReducer(state = initialState, action: ResponseCacheAction): ResponseCacheState {
switch (action.type) {
case ResponseCacheActionTypes.ADD: {
return addToCache(state, action as ResponseCacheAddAction);
}
case ResponseCacheActionTypes.REMOVE: {
return removeFromCache(state, action as ResponseCacheRemoveAction);
}
case ResponseCacheActionTypes.RESET_TIMESTAMPS: {
return resetResponseCacheTimestamps(state, action as ResetResponseCacheTimestampsAction)
}
default: {
return state;
}
}
}
function addToCache(state: ResponseCacheState, action: ResponseCacheAddAction): ResponseCacheState {
return Object.assign({}, state, {
[action.payload.key]: {
key: action.payload.key,
response: action.payload.response,
timeAdded: action.payload.timeAdded,
msToLive: action.payload.msToLive
}
});
}
/**
* Remove a request from the cache
*
* @param state
* the current state
* @param action
* an ResponseCacheRemoveAction
* @return ResponseCacheState
* the new state, with the request removed if it existed.
*/
function removeFromCache(state: ResponseCacheState, action: ResponseCacheRemoveAction): ResponseCacheState {
if (hasValue(state[action.payload])) {
const newCache = Object.assign({}, state);
delete newCache[action.payload];
return newCache;
} else {
return state;
}
}
/**
* Set the timeAdded timestamp of every cached request to the specified value
*
* @param state
* the current state
* @param action
* a ResetResponseCacheTimestampsAction
* @return ResponseCacheState
* the new state, with all timeAdded timestamps set to the specified value
*/
function resetResponseCacheTimestamps(state: ResponseCacheState, action: ResetResponseCacheTimestampsAction): ResponseCacheState {
const newState = Object.create(null);
Object.keys(state).forEach((key) => {
newState[key] = Object.assign({}, state[key], {
timeAdded: action.payload
});
});
return newState;
}

View File

@@ -1,100 +0,0 @@
import { Store } from '@ngrx/store';
import { ResponseCacheService } from './response-cache.service';
import { of as observableOf } from 'rxjs';
import { CoreState } from '../core.reducers';
import { RestResponse } from './response-cache.models';
import { ResponseCacheEntry } from './response-cache.reducer';
import { first } from 'rxjs/operators';
import * as ngrx from '@ngrx/store'
import { cold } from 'jasmine-marbles';
describe('ResponseCacheService', () => {
let service: ResponseCacheService;
let store: Store<CoreState>;
const keys = ['125c17f89046283c5f0640722aac9feb', 'a06c3006a41caec5d635af099b0c780c'];
const timestamp = new Date().getTime();
const validCacheEntry = (key) => {
return {
key: key,
response: new RestResponse(true, '200'),
timeAdded: timestamp,
msToLive: 24 * 60 * 60 * 1000 // a day
}
};
const invalidCacheEntry = (key) => {
return {
key: key,
response: new RestResponse(true, '200'),
timeAdded: 0,
msToLive: 0
}
};
beforeEach(() => {
store = new Store<CoreState>(undefined, undefined, undefined);
spyOn(store, 'dispatch');
service = new ResponseCacheService(store);
spyOn(Date.prototype, 'getTime').and.callFake(() => {
return timestamp;
});
});
describe('get', () => {
it('should return an observable of the cached request with the specified key', () => {
spyOnProperty(ngrx, 'select').and.callFake(() => {
return () => {
return () => observableOf(validCacheEntry(keys[1]));
};
});
let testObj: ResponseCacheEntry;
service.get(keys[1]).pipe(first()).subscribe((entry) => {
testObj = entry;
});
expect(testObj.key).toEqual(keys[1]);
});
it('should not return a cached request that has exceeded its time to live', () => {
spyOnProperty(ngrx, 'select').and.callFake(() => {
return () => {
return () => observableOf(invalidCacheEntry(keys[1]));
};
});
let getObsHasFired = false;
const subscription = service.get(keys[1]).subscribe((entry) => getObsHasFired = true);
expect(getObsHasFired).toBe(false);
subscription.unsubscribe();
});
});
describe('has', () => {
it('should return true if the request with the supplied key is cached and still valid', () => {
spyOnProperty(ngrx, 'select').and.callFake(() => {
return () => {
return () => observableOf(validCacheEntry(keys[1]));
};
});
expect(service.has(keys[1])).toBe(true);
});
it('should return false if the request with the supplied key isn\'t cached', () => {
spyOnProperty(ngrx, 'select').and.callFake(() => {
return () => {
return () => observableOf(undefined);
};
});
expect(service.has(keys[1])).toBe(false);
});
it('should return false if the request with the supplied key is cached but has exceeded its time to live', () => {
spyOnProperty(ngrx, 'select').and.callFake(() => {
return () => {
return () => observableOf(invalidCacheEntry(keys[1]));
};
});
expect(service.has(keys[1])).toBe(false);
});
});
});

View File

@@ -1,100 +0,0 @@
import { filter, take, distinctUntilChanged, first } from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { MemoizedSelector, select, Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { ResponseCacheEntry } from './response-cache.reducer';
import { hasNoValue } from '../../shared/empty.util';
import { ResponseCacheRemoveAction, ResponseCacheAddAction } from './response-cache.actions';
import { RestResponse } from './response-cache.models';
import { coreSelector, CoreState } from '../core.reducers';
import { pathSelector } from '../shared/selectors';
function entryFromKeySelector(key: string): MemoizedSelector<CoreState, ResponseCacheEntry> {
return pathSelector<CoreState, ResponseCacheEntry>(coreSelector, 'data/response', key);
}
/**
* A service to interact with the response cache
*/
@Injectable()
export class ResponseCacheService {
constructor(
private store: Store<CoreState>
) {
}
add(key: string, response: RestResponse, msToLive: number): Observable<ResponseCacheEntry> {
if (!this.has(key)) {
this.store.dispatch(new ResponseCacheAddAction(key, response, new Date().getTime(), msToLive));
}
return this.get(key);
}
/**
* Get an observable of the response with the specified key
*
* @param key
* the key of the response to get
* @return Observable<ResponseCacheEntry>
* an observable of the ResponseCacheEntry with the specified key
*/
get(key: string): Observable<ResponseCacheEntry> {
return this.store.pipe(
select(entryFromKeySelector(key)),
filter((entry: ResponseCacheEntry) => this.isValid(entry)),
distinctUntilChanged()
)
}
/**
* Check whether the response with the specified key is cached
*
* @param key
* the key of the response to check
* @return boolean
* true if the response with the specified key is cached,
* false otherwise
*/
has(key: string): boolean {
let result: boolean;
this.store.pipe(select(entryFromKeySelector(key)),
first()
).subscribe((entry: ResponseCacheEntry) => {
result = this.isValid(entry);
});
return result;
}
remove(key: string): void {
if (this.has(key)) {
this.store.dispatch(new ResponseCacheRemoveAction(key));
}
}
/**
* Check whether a ResponseCacheEntry should still be cached
*
* @param entry
* the entry to check
* @return boolean
* false if the entry is null, undefined, or its time to
* live has been exceeded, true otherwise
*/
private isValid(entry: ResponseCacheEntry): boolean {
if (hasNoValue(entry)) {
return false;
} else {
const timeOutdated = entry.timeAdded + entry.msToLive;
const isOutDated = new Date().getTime() > timeOutdated;
if (isOutDated) {
this.store.dispatch(new ResponseCacheRemoveAction(entry.key));
}
return !isOutDated;
}
}
}

View File

@@ -10,10 +10,11 @@ import { MetadataSchema } from '../metadata/metadataschema.model';
import { RegistryMetadatafieldsResponse } from '../registry/registry-metadatafields-response.model'; import { RegistryMetadatafieldsResponse } from '../registry/registry-metadatafields-response.model';
import { RegistryBitstreamformatsResponse } from '../registry/registry-bitstreamformats-response.model'; import { RegistryBitstreamformatsResponse } from '../registry/registry-bitstreamformats-response.model';
import { AuthStatus } from '../auth/models/auth-status.model'; import { AuthStatus } from '../auth/models/auth-status.model';
import { DSpaceObject } from '../shared/dspace-object.model';
/* tslint:disable:max-classes-per-file */ /* tslint:disable:max-classes-per-file */
export class RestResponse { export class RestResponse {
public toCache = true; public timeAdded: number;
constructor( constructor(
public isSuccessful: boolean, public isSuccessful: boolean,

View File

@@ -0,0 +1,82 @@
import { Action } from '@ngrx/store';
import { type } from '../../shared/ngrx/type';
import { RestRequestMethod } from '../data/rest-request-method';
/**
* The list of ServerSyncBufferAction type definitions
*/
export const ServerSyncBufferActionTypes = {
ADD: type('dspace/core/cache/syncbuffer/ADD'),
COMMIT: type('dspace/core/cache/syncbuffer/COMMIT'),
EMPTY: type('dspace/core/cache/syncbuffer/EMPTY'),
};
/* tslint:disable:max-classes-per-file */
/**
* An ngrx action to add a new cached object to the server sync buffer
*/
export class AddToSSBAction implements Action {
type = ServerSyncBufferActionTypes.ADD;
payload: {
href: string,
method: RestRequestMethod
};
/**
* Create a new AddToSSBAction
*
* @param href
* the unique href of the cached object entry that should be updated
*/
constructor(href: string, method: RestRequestMethod) {
this.payload = { href, method: method };
}
}
/**
* An ngrx action to commit everything (for a certain method, when specified) in the ServerSyncBuffer to the server
*/
export class CommitSSBAction implements Action {
type = ServerSyncBufferActionTypes.COMMIT;
payload?: RestRequestMethod;
/**
* Create a new CommitSSBAction
*
* @param method
* an optional method for which the ServerSyncBuffer should send its entries to the server
*/
constructor(method?: RestRequestMethod) {
this.payload = method;
}
}
/**
* An ngrx action to remove everything (for a certain method, when specified) from the ServerSyncBuffer to the server
*/
export class EmptySSBAction implements Action {
type = ServerSyncBufferActionTypes.EMPTY;
payload?: RestRequestMethod;
/**
* Create a new EmptySSBAction
*
* @param method
* an optional method for which the ServerSyncBuffer should remove its entries
* if this parameter is omitted, the buffer will be emptied as a whole
*/
constructor(method?: RestRequestMethod) {
this.payload = method;
}
}
/* tslint:enable:max-classes-per-file */
/**
* A type to encompass all ServerSyncBufferActions
*/
export type ServerSyncBufferAction
= AddToSSBAction
| CommitSSBAction
| EmptySSBAction

View File

@@ -0,0 +1,139 @@
import { TestBed } from '@angular/core/testing';
import { Observable, of as observableOf } from 'rxjs';
import { provideMockActions } from '@ngrx/effects/testing';
import { cold, hot } from 'jasmine-marbles';
import { ServerSyncBufferEffects } from './server-sync-buffer.effects';
import { GLOBAL_CONFIG } from '../../../config';
import {
CommitSSBAction,
EmptySSBAction,
ServerSyncBufferActionTypes
} from './server-sync-buffer.actions';
import { RestRequestMethod } from '../data/rest-request-method';
import { Store } from '@ngrx/store';
import { RequestService } from '../data/request.service';
import { ObjectCacheService } from './object-cache.service';
import { MockStore } from '../../shared/testing/mock-store';
import { ObjectCacheState } from './object-cache.reducer';
import * as operators from 'rxjs/operators';
import { spyOnOperator } from '../../shared/testing/utils';
import { DSpaceObject } from '../shared/dspace-object.model';
import { getMockRequestService } from '../../shared/mocks/mock-request.service';
import { ApplyPatchObjectCacheAction } from './object-cache.actions';
describe('ServerSyncBufferEffects', () => {
let ssbEffects: ServerSyncBufferEffects;
let actions: Observable<any>;
const testConfig = {
cache:
{
autoSync:
{
timePerMethod: {},
defaultTime: 0
}
}
};
const selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7';
let store;
beforeEach(() => {
store = new MockStore<ObjectCacheState>({});
TestBed.configureTestingModule({
providers: [
ServerSyncBufferEffects,
provideMockActions(() => actions),
{ provide: GLOBAL_CONFIG, useValue: testConfig },
{ provide: RequestService, useValue: getMockRequestService() },
{
provide: ObjectCacheService, useValue: {
getBySelfLink: (link) => {
const object = new DSpaceObject();
object.self = link;
return observableOf(object);
}
}
},
{ provide: Store, useValue: store }
// other providers
],
});
ssbEffects = TestBed.get(ServerSyncBufferEffects);
});
describe('setTimeoutForServerSync', () => {
beforeEach(() => {
spyOnOperator(operators, 'delay').and.returnValue((v) => v);
});
it('should return a COMMIT action in response to an ADD action', () => {
actions = hot('a', {
a: {
type: ServerSyncBufferActionTypes.ADD,
payload: { href: selfLink, method: RestRequestMethod.PUT }
}
});
const expected = cold('b', { b: new CommitSSBAction(RestRequestMethod.PUT) });
expect(ssbEffects.setTimeoutForServerSync).toBeObservable(expected);
});
});
describe('commitServerSyncBuffer', () => {
describe('when the buffer is not empty', () => {
beforeEach(() => {
store
.subscribe((state) => {
(state as any).core = Object({});
(state as any).core['cache/syncbuffer'] = {
buffer: [{
href: selfLink,
method: RestRequestMethod.PATCH
}]
};
});
});
it('should return a list of actions in response to a COMMIT action', () => {
actions = hot('a', {
a: {
type: ServerSyncBufferActionTypes.COMMIT,
payload: RestRequestMethod.PATCH
}
});
const expected = cold('(bc)', {
b: new ApplyPatchObjectCacheAction(selfLink),
c: new EmptySSBAction(RestRequestMethod.PATCH)
});
expect(ssbEffects.commitServerSyncBuffer).toBeObservable(expected);
});
});
describe('when the buffer is empty', () => {
beforeEach(() => {
store
.subscribe((state) => {
(state as any).core = Object({});
(state as any).core['cache/syncbuffer'] = {
buffer: []
};
});
});
it('should return a placeholder action in response to a COMMIT action', () => {
store.subscribe();
actions = hot('a', {
a: {
type: ServerSyncBufferActionTypes.COMMIT,
payload: { method: RestRequestMethod.PATCH }
}
});
const expected = cold('b', { b: { type: 'NO_ACTION' } });
expect(ssbEffects.commitServerSyncBuffer).toBeObservable(expected);
});
});
});
});

View File

@@ -0,0 +1,122 @@
import { delay, exhaustMap, map, switchMap, take } from 'rxjs/operators';
import { Inject, Injectable } from '@angular/core';
import { Actions, Effect, ofType } from '@ngrx/effects';
import {
AddToSSBAction,
CommitSSBAction,
EmptySSBAction,
ServerSyncBufferActionTypes
} from './server-sync-buffer.actions';
import { GLOBAL_CONFIG } from '../../../config';
import { GlobalConfig } from '../../../config/global-config.interface';
import { coreSelector, CoreState } from '../core.reducers';
import { Action, createSelector, MemoizedSelector, select, Store } from '@ngrx/store';
import { ServerSyncBufferEntry, ServerSyncBufferState } from './server-sync-buffer.reducer';
import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs';
import { RequestService } from '../data/request.service';
import { PutRequest } from '../data/request.models';
import { ObjectCacheService } from './object-cache.service';
import { ApplyPatchObjectCacheAction } from './object-cache.actions';
import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer';
import { GenericConstructor } from '../shared/generic-constructor';
import { hasValue, isNotEmpty, isNotUndefined } from '../../shared/empty.util';
import { Observable } from 'rxjs/internal/Observable';
import { RestRequestMethod } from '../data/rest-request-method';
@Injectable()
export class ServerSyncBufferEffects {
/**
* When an ADDToSSBAction is dispatched
* Set a time out (configurable per method type)
* Then dispatch a CommitSSBAction
* When the delay is running, no new AddToSSBActions are processed in this effect
*/
@Effect() setTimeoutForServerSync = this.actions$
.pipe(
ofType(ServerSyncBufferActionTypes.ADD),
exhaustMap((action: AddToSSBAction) => {
const autoSyncConfig = this.EnvConfig.cache.autoSync;
const timeoutInSeconds = autoSyncConfig.timePerMethod[action.payload.method] || autoSyncConfig.defaultTime;
return observableOf(new CommitSSBAction(action.payload.method)).pipe(
delay(timeoutInSeconds * 1000),
)
})
);
/**
* When a CommitSSBAction is dispatched
* Create a list of actions for each entry in the current buffer state to be dispatched
* When the list of actions is not empty, also dispatch an EmptySSBAction
* When the list is empty dispatch a NO_ACTION placeholder action
*/
@Effect() commitServerSyncBuffer = this.actions$
.pipe(
ofType(ServerSyncBufferActionTypes.COMMIT),
switchMap((action: CommitSSBAction) => {
return this.store.pipe(
select(serverSyncBufferSelector()),
take(1), /* necessary, otherwise delay will not have any effect after the first run */
switchMap((bufferState: ServerSyncBufferState) => {
const actions: Array<Observable<Action>> = bufferState.buffer
.filter((entry: ServerSyncBufferEntry) => {
/* If there's a request method, filter
If there's no filter, commit everything */
if (hasValue(action.payload)) {
return entry.method === action.payload;
}
return true;
})
.map((entry: ServerSyncBufferEntry) => {
if (entry.method === RestRequestMethod.PATCH) {
return this.applyPatch(entry.href);
} else {
/* TODO implement for other request method types */
}
});
/* Add extra action to array, to make sure the ServerSyncBuffer is emptied afterwards */
if (isNotEmpty(actions) && isNotUndefined(actions[0])) {
return observableCombineLatest(...actions).pipe(
switchMap((array) => [...array, new EmptySSBAction(action.payload)])
);
} else {
return observableOf({ type: 'NO_ACTION' });
}
})
)
})
);
/**
* private method to create an ApplyPatchObjectCacheAction based on a cache entry
* and to do the actual patch request to the server
* @param {string} href The self link of the cache entry
* @returns {Observable<Action>} ApplyPatchObjectCacheAction to be dispatched
*/
private applyPatch(href: string): Observable<Action> {
const patchObject = this.objectCache.getBySelfLink(href).pipe(take(1));
return patchObject.pipe(
map((object) => {
const serializedObject = new DSpaceRESTv2Serializer(object.constructor as GenericConstructor<{}>).serialize(object);
this.requestService.configure(new PutRequest(this.requestService.generateRequestId(), href, serializedObject));
return new ApplyPatchObjectCacheAction(href)
})
)
}
constructor(private actions$: Actions,
private store: Store<CoreState>,
private requestService: RequestService,
private objectCache: ObjectCacheService,
@Inject(GLOBAL_CONFIG) private EnvConfig: GlobalConfig) {
}
}
export function serverSyncBufferSelector(): MemoizedSelector<CoreState, ServerSyncBufferState> {
return createSelector(coreSelector, (state: CoreState) => state['cache/syncbuffer']);
}

View File

@@ -0,0 +1,85 @@
import * as deepFreeze from 'deep-freeze';
import { RemoveFromObjectCacheAction } from './object-cache.actions';
import { serverSyncBufferReducer } from './server-sync-buffer.reducer';
import { RestRequestMethod } from '../data/rest-request-method';
import { AddToSSBAction, EmptySSBAction } from './server-sync-buffer.actions';
class NullAction extends RemoveFromObjectCacheAction {
type = null;
payload = null;
constructor() {
super(null);
}
}
describe('serverSyncBufferReducer', () => {
const selfLink1 = 'https://localhost:8080/api/core/items/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7';
const selfLink2 = 'https://localhost:8080/api/core/items/28b04544-1766-4e82-9728-c4e93544ecd3';
const testState = {
buffer:
[
{
href: selfLink1,
method: RestRequestMethod.PATCH,
},
{
href: selfLink2,
method: RestRequestMethod.GET,
}
]
};
const newSelfLink = 'https://localhost:8080/api/core/items/1ce6b5ae-97e1-4e5a-b4b0-f9029bad10c0';
deepFreeze(testState);
it('should return the current state when no valid actions have been made', () => {
const action = new NullAction();
const newState = serverSyncBufferReducer(testState, action);
expect(newState).toEqual(testState);
});
it('should start with an empty buffer array', () => {
const action = new NullAction();
const initialState = serverSyncBufferReducer(undefined, action);
expect(initialState).toEqual({ buffer: [] });
});
it('should perform the ADD action without affecting the previous state', () => {
const action = new AddToSSBAction(selfLink1, RestRequestMethod.POST);
// testState has already been frozen above
serverSyncBufferReducer(testState, action);
});
it('should perform the EMPTY action without affecting the previous state', () => {
const action = new EmptySSBAction();
// testState has already been frozen above
serverSyncBufferReducer(testState, action);
});
it('should empty the buffer if the EmptySSBAction is dispatched without a payload', () => {
const action = new EmptySSBAction();
// testState has already been frozen above
const emptyState = serverSyncBufferReducer(testState, action);
expect(emptyState).toEqual({ buffer: [] });
});
it('should empty the buffer partially if the EmptySSBAction is dispatched with a payload', () => {
const action = new EmptySSBAction(RestRequestMethod.PATCH);
// testState has already been frozen above
const emptyState = serverSyncBufferReducer(testState, action);
expect(emptyState).toEqual({ buffer: testState.buffer.filter((entry) => entry.method !== RestRequestMethod.PATCH) });
});
it('should add an entry to the buffer if the AddSSBAction is dispatched', () => {
const action = new AddToSSBAction(newSelfLink, RestRequestMethod.PUT);
// testState has already been frozen above
const newState = serverSyncBufferReducer(testState, action);
expect(newState.buffer).toContain({
href: newSelfLink, method: RestRequestMethod.PUT
})
;
})
});

View File

@@ -0,0 +1,92 @@
import { hasNoValue, hasValue } from '../../shared/empty.util';
import {
AddToSSBAction,
EmptySSBAction,
ServerSyncBufferAction,
ServerSyncBufferActionTypes
} from './server-sync-buffer.actions';
import { RestRequestMethod } from '../data/rest-request-method';
/**
* An entry in the ServerSyncBufferState
* href: unique href of an ObjectCacheEntry
* method: RestRequestMethod type
*/
export class ServerSyncBufferEntry {
href: string;
method: RestRequestMethod;
}
/**
* The ServerSyncBuffer State
*
* Consists list of ServerSyncBufferState
*/
export interface ServerSyncBufferState {
buffer: ServerSyncBufferEntry[];
}
// Object.create(null) ensures the object has no default js properties (e.g. `__proto__`)
const initialState: ServerSyncBufferState = { buffer: [] };
/**
* The ServerSyncBuffer Reducer
*
* @param state
* the current state
* @param action
* the action to perform on the state
* @return ServerSyncBufferState
* the new state
*/
export function serverSyncBufferReducer(state = initialState, action: ServerSyncBufferAction): ServerSyncBufferState {
switch (action.type) {
case ServerSyncBufferActionTypes.ADD: {
return addToServerSyncQueue(state, action as AddToSSBAction)
}
case ServerSyncBufferActionTypes.EMPTY: {
return emptyServerSyncQueue(state, action as EmptySSBAction);
}
default: {
return state;
}
}
}
/**
* Add a new entry to the buffer with a specified method
*
* @param state
* the current state
* @param action
* an AddToSSBAction
* @return ServerSyncBufferState
* the new state, with a new entry added to the buffer
*/
function addToServerSyncQueue(state: ServerSyncBufferState, action: AddToSSBAction): ServerSyncBufferState {
const actionEntry = action.payload as ServerSyncBufferEntry;
if (hasNoValue(state.buffer.find((entry) => entry.href === actionEntry.href && entry.method === actionEntry.method))) {
return Object.assign({}, state, { buffer: state.buffer.concat(actionEntry) });
}
}
/**
* Remove all ServerSyncBuffers entry from the buffer with a specified method
* If no method is specified, empty the whole buffer
*
* @param state
* the current state
* @param action
* an AddToSSBAction
* @return ServerSyncBufferState
* the new state, with a new entry added to the buffer
*/
function emptyServerSyncQueue(state: ServerSyncBufferState, action: EmptySSBAction): ServerSyncBufferState {
let newBuffer = [];
if (hasValue(action.payload)) {
newBuffer = state.buffer.filter((entry) => entry.method !== action.payload);
}
return Object.assign({}, state, { buffer: newBuffer });
}

View File

@@ -1,7 +1,6 @@
import { cold, getTestScheduler, hot } from 'jasmine-marbles'; import { cold, getTestScheduler, hot } from 'jasmine-marbles';
import { TestScheduler } from 'rxjs/testing'; import { TestScheduler } from 'rxjs/testing';
import { getMockRequestService } from '../../shared/mocks/mock-request.service'; import { getMockRequestService } from '../../shared/mocks/mock-request.service';
import { ResponseCacheService } from '../cache/response-cache.service';
import { ConfigService } from './config.service'; import { ConfigService } from './config.service';
import { RequestService } from '../data/request.service'; import { RequestService } from '../data/request.service';
import { ConfigRequest, FindAllOptions } from '../data/request.models'; import { ConfigRequest, FindAllOptions } from '../data/request.models';
@@ -16,7 +15,6 @@ class TestService extends ConfigService {
protected browseEndpoint = BROWSE; protected browseEndpoint = BROWSE;
constructor( constructor(
protected responseCache: ResponseCacheService,
protected requestService: RequestService, protected requestService: RequestService,
protected halService: HALEndpointService) { protected halService: HALEndpointService) {
super(); super();
@@ -26,7 +24,6 @@ class TestService extends ConfigService {
describe('ConfigService', () => { describe('ConfigService', () => {
let scheduler: TestScheduler; let scheduler: TestScheduler;
let service: TestService; let service: TestService;
let responseCache: ResponseCacheService;
let requestService: RequestService; let requestService: RequestService;
let halService: any; let halService: any;
@@ -39,17 +36,8 @@ describe('ConfigService', () => {
const scopedEndpoint = `${serviceEndpoint}/${scopeName}`; const scopedEndpoint = `${serviceEndpoint}/${scopeName}`;
const searchEndpoint = `${serviceEndpoint}/${BROWSE}?uuid=${scopeID}`; const searchEndpoint = `${serviceEndpoint}/${BROWSE}?uuid=${scopeID}`;
function initMockResponseCacheService(isSuccessful: boolean): ResponseCacheService {
return jasmine.createSpyObj('responseCache', {
get: cold('c-', {
c: { response: { isSuccessful } }
})
});
}
function initTestService(): TestService { function initTestService(): TestService {
return new TestService( return new TestService(
responseCache,
requestService, requestService,
halService halService
); );
@@ -57,7 +45,6 @@ describe('ConfigService', () => {
beforeEach(() => { beforeEach(() => {
scheduler = getTestScheduler(); scheduler = getTestScheduler();
responseCache = initMockResponseCacheService(true);
requestService = getMockRequestService(); requestService = getMockRequestService();
halService = new HALEndpointServiceStub(configEndpoint); halService = new HALEndpointServiceStub(configEndpoint);
service = initTestService(); service = initTestService();

View File

@@ -1,24 +1,25 @@
import { Observable, of as observableOf, throwError as observableThrowError, merge as observableMerge } from 'rxjs'; import { merge as observableMerge, Observable, throwError as observableThrowError } from 'rxjs';
import { distinctUntilChanged, filter, map, mergeMap, tap } from 'rxjs/operators'; import { distinctUntilChanged, filter, map, mergeMap, tap } from 'rxjs/operators';
import { RequestService } from '../data/request.service'; import { RequestService } from '../data/request.service';
import { ResponseCacheService } from '../cache/response-cache.service'; import { ConfigSuccessResponse } from '../cache/response.models';
import { ConfigSuccessResponse } 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 { hasValue, isNotEmpty } from '../../shared/empty.util'; 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';
import { RequestEntry } from '../data/request.reducer';
import { getResponseFromEntry } from '../shared/operators';
export abstract class ConfigService { export abstract class ConfigService {
protected request: ConfigRequest; protected request: ConfigRequest;
protected abstract responseCache: ResponseCacheService;
protected abstract requestService: RequestService; protected abstract requestService: RequestService;
protected abstract linkPath: string; protected abstract linkPath: string;
protected abstract browseEndpoint: string; protected abstract browseEndpoint: string;
protected abstract halService: HALEndpointService; protected abstract halService: HALEndpointService;
protected getConfig(request: RestRequest): Observable<ConfigData> { protected getConfig(request: RestRequest): Observable<ConfigData> {
const responses = this.responseCache.get(request.href).pipe(map((entry: ResponseCacheEntry) => entry.response)); const responses = this.requestService.getByHref(request.href).pipe(
getResponseFromEntry()
);
const errorResponses = responses.pipe( const errorResponses = responses.pipe(
filter((response) => !response.isSuccessful), filter((response) => !response.isSuccessful),
mergeMap(() => observableThrowError(new Error(`Couldn't retrieve the config`))) mergeMap(() => observableThrowError(new Error(`Couldn't retrieve the config`)))
@@ -94,7 +95,6 @@ export abstract class ConfigService {
} }
public getConfigBySearch(options: FindAllOptions = {}): Observable<ConfigData> { public getConfigBySearch(options: FindAllOptions = {}): Observable<ConfigData> {
console.log(this.halService.getEndpoint(this.linkPath));
return this.halService.getEndpoint(this.linkPath).pipe( return this.halService.getEndpoint(this.linkPath).pipe(
map((endpoint: string) => this.getConfigSearchHref(endpoint, options)), map((endpoint: string) => this.getConfigSearchHref(endpoint, options)),
filter((href: string) => isNotEmpty(href)), filter((href: string) => isNotEmpty(href)),

View File

@@ -1,7 +1,6 @@
import { 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 { RequestService } from '../data/request.service'; import { RequestService } from '../data/request.service';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
@@ -11,7 +10,6 @@ export class SubmissionDefinitionsConfigService extends ConfigService {
protected browseEndpoint = 'search/findByCollection'; protected browseEndpoint = 'search/findByCollection';
constructor( constructor(
protected responseCache: ResponseCacheService,
protected requestService: RequestService, protected requestService: RequestService,
protected halService: HALEndpointService) { protected halService: HALEndpointService) {
super(); super();

View File

@@ -1,7 +1,6 @@
import { 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 { RequestService } from '../data/request.service'; import { RequestService } from '../data/request.service';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
@@ -11,7 +10,6 @@ export class SubmissionFormsConfigService extends ConfigService {
protected browseEndpoint = ''; protected browseEndpoint = '';
constructor( constructor(
protected responseCache: ResponseCacheService,
protected requestService: RequestService, protected requestService: RequestService,
protected halService: HALEndpointService) { protected halService: HALEndpointService) {
super(); super();

View File

@@ -1,7 +1,6 @@
import { 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 { RequestService } from '../data/request.service'; import { RequestService } from '../data/request.service';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
@@ -11,7 +10,6 @@ export class SubmissionSectionsConfigService extends ConfigService {
protected browseEndpoint = ''; protected browseEndpoint = '';
constructor( constructor(
protected responseCache: ResponseCacheService,
protected requestService: RequestService, protected requestService: RequestService,
protected halService: HALEndpointService) { protected halService: HALEndpointService) {
super(); super();

View File

@@ -1,14 +1,14 @@
import { ObjectCacheEffects } from './cache/object-cache.effects'; import { ObjectCacheEffects } from './cache/object-cache.effects';
import { ResponseCacheEffects } from './cache/response-cache.effects';
import { UUIDIndexEffects } from './index/index.effects'; import { UUIDIndexEffects } from './index/index.effects';
import { RequestEffects } from './data/request.effects'; import { RequestEffects } from './data/request.effects';
import { AuthEffects } from './auth/auth.effects'; import { AuthEffects } from './auth/auth.effects';
import { ServerSyncBufferEffects } from './cache/server-sync-buffer.effects';
export const coreEffects = [ export const coreEffects = [
ResponseCacheEffects,
RequestEffects, RequestEffects,
ObjectCacheEffects, ObjectCacheEffects,
UUIDIndexEffects, UUIDIndexEffects,
AuthEffects AuthEffects,
ServerSyncBufferEffects
]; ];

View File

@@ -32,7 +32,6 @@ import { ObjectCacheService } from './cache/object-cache.service';
import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model';
import { RemoteDataBuildService } from './cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from './cache/builders/remote-data-build.service';
import { RequestService } from './data/request.service'; import { RequestService } from './data/request.service';
import { ResponseCacheService } from './cache/response-cache.service';
import { EndpointMapResponseParsingService } from './data/endpoint-map-response-parsing.service'; import { EndpointMapResponseParsingService } from './data/endpoint-map-response-parsing.service';
import { ServerResponseService } from '../shared/services/server-response.service'; import { ServerResponseService } from '../shared/services/server-response.service';
import { NativeWindowFactory, NativeWindowService } from '../shared/services/window.service'; import { NativeWindowFactory, NativeWindowService } from '../shared/services/window.service';
@@ -66,6 +65,8 @@ import { BrowseItemsResponseParsingService } from './data/browse-items-response-
import { DSpaceObjectDataService } from './data/dspace-object-data.service'; import { DSpaceObjectDataService } from './data/dspace-object-data.service';
import { CSSVariableService } from '../shared/sass-helper/sass-helper.service'; import { CSSVariableService } from '../shared/sass-helper/sass-helper.service';
import { MenuService } from '../shared/menu/menu.service'; import { MenuService } from '../shared/menu/menu.service';
import { NormalizedObjectBuildService } from './cache/builders/normalized-object-build.service';
import { DSOChangeAnalyzer } from './data/dso-change-analyzer.service';
const IMPORTS = [ const IMPORTS = [
CommonModule, CommonModule,
@@ -102,9 +103,9 @@ const PROVIDERS = [
ObjectCacheService, ObjectCacheService,
PaginationComponentOptions, PaginationComponentOptions,
RegistryService, RegistryService,
NormalizedObjectBuildService,
RemoteDataBuildService, RemoteDataBuildService,
RequestService, RequestService,
ResponseCacheService,
EndpointMapResponseParsingService, EndpointMapResponseParsingService,
FacetValueResponseParsingService, FacetValueResponseParsingService,
FacetValueMapResponseParsingService, FacetValueMapResponseParsingService,
@@ -130,6 +131,7 @@ const PROVIDERS = [
UploaderService, UploaderService,
UUIDService, UUIDService,
DSpaceObjectDataService, DSpaceObjectDataService,
DSOChangeAnalyzer,
CSSVariableService, CSSVariableService,
MenuService, MenuService,
// register AuthInterceptor as HttpInterceptor // register AuthInterceptor as HttpInterceptor

Some files were not shown because too many files have changed in this diff Show More