Merge remote-tracking branch 'atmire/master' into Browse-by-links

Conflicts:
	src/app/core/browse/browse.service.spec.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-13 13:25:35 +01:00
194 changed files with 4513 additions and 1751 deletions

View File

@@ -18,9 +18,16 @@ module.exports = {
// Caching settings
cache: {
// 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
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: {

View File

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

View File

@@ -13,6 +13,38 @@
"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": {
@@ -25,6 +57,36 @@
},
"sub-community-list": {
"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": {

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 { 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({
imports: [
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',
component: CollectionPageComponent,
@@ -19,6 +47,7 @@ import { CollectionPageResolver } from './collection-page.resolver';
],
providers: [
CollectionPageResolver,
CreateCollectionPageGuard
]
})
export class CollectionPageRoutingModule {

View File

@@ -15,7 +15,7 @@ import { Item } from '../core/shared/item.model';
import { fadeIn, fadeInOut } from '../shared/animations/fade';
import { hasValue, isNotEmpty } from '../shared/empty.util';
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 { PaginatedSearchOptions } from '../+search-page/paginated-search-options.model';
import { toDSpaceObjectListRD } from '../core/shared/operators';
@@ -55,7 +55,8 @@ export class CollectionPageComponent implements OnInit, OnDestroy {
ngOnInit(): void {
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(
map((rd: RemoteData<Collection>) => rd.payload),
@@ -75,8 +76,8 @@ export class CollectionPageComponent implements OnInit, OnDestroy {
pagination: pagination,
sort: this.sortConfig
});
})
);
}));
}
updatePage(searchOptions) {

View File

@@ -5,7 +5,11 @@ import { SharedModule } from '../shared/shared.module';
import { CollectionPageComponent } from './collection-page.component';
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 { EditCollectionPageComponent } from './edit-collection-page/edit-collection-page.component';
import { DeleteCollectionPageComponent } from './delete-collection-page/delete-collection-page.component';
@NgModule({
imports: [
@@ -16,6 +20,10 @@ import { SearchPageModule } from '../+search-page/search-page.module';
],
declarations: [
CollectionPageComponent,
CreateCollectionPageComponent,
EditCollectionPageComponent,
DeleteCollectionPageComponent,
CollectionFormComponent
]
})
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 { 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({
imports: [
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',
component: CommunityPageComponent,
@@ -19,6 +47,7 @@ import { CommunityPageResolver } from './community-page.resolver';
],
providers: [
CommunityPageResolver,
CreateCommunityPageGuard
]
})
export class CommunityPageRoutingModule {

View File

@@ -30,6 +30,7 @@
<ds-community-page-sub-collection-list [community]="communityPayload"></ds-community-page-sub-collection-list>
</div>
</div>
<ds-error *ngIf="communityRD?.hasFailed" message="{{'error.community' | translate}}"></ds-error>
<ds-loading *ngIf="communityRD?.isLoading" message="{{'loading.community' | translate}}"></ds-loading>
</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 { ActivatedRoute } from '@angular/router';
@@ -45,5 +45,4 @@ export class CommunityPageComponent implements OnInit, OnDestroy {
ngOnDestroy(): void {
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 { CommunityPageRoutingModule } from './community-page-routing.module';
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({
imports: [
@@ -18,8 +22,13 @@ import {CommunityPageSubCommunityListComponent} from './sub-community-list/commu
CommunityPageComponent,
CommunityPageSubCollectionListComponent,
CommunityPageSubCommunityListComponent,
CreateCommunityPageComponent,
EditCommunityPageComponent,
DeleteCommunityPageComponent,
CommunityFormComponent
]
})
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>;
const subcommunities = [Object.assign(new Community(), {
name: 'SubCommunity 1',
id: '123456789-1',
metadata: [
{
@@ -27,7 +26,6 @@ describe('SubCommunityList Component', () => {
}]
}),
Object.assign(new Community(), {
name: 'SubCommunity 2',
id: '123456789-2',
metadata: [
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,22 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {Item} from '../../../core/shared/item.model';
import {RouterStub} from '../../../shared/testing/router-stub';
import {CommonModule} from '@angular/common';
import {RouterTestingModule} from '@angular/router/testing';
import {TranslateModule} from '@ngx-translate/core';
import {NgbModule} from '@ng-bootstrap/ng-bootstrap';
import {ActivatedRoute, Router} from '@angular/router';
import {NotificationsServiceStub} from '../../../shared/testing/notifications-service-stub';
import {NotificationsService} from '../../../shared/notifications/notifications.service';
import {Component, CUSTOM_ELEMENTS_SCHEMA} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {ItemDataService} from '../../../core/data/item-data.service';
import {RemoteData} from '../../../core/data/remote-data';
import {AbstractSimpleItemActionComponent} from './abstract-simple-item-action.component';
import {By} from '@angular/platform-browser';
import {RestResponse} from '../../../core/cache/response-cache.models';
import {of as observableOf} from 'rxjs';
import {getItemEditPath} from '../../item-page-routing.module';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { Item } from '../../../core/shared/item.model';
import { RouterStub } from '../../../shared/testing/router-stub';
import { CommonModule } from '@angular/common';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateModule } from '@ngx-translate/core';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { ActivatedRoute, Router } from '@angular/router';
import { NotificationsServiceStub } from '../../../shared/testing/notifications-service-stub';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ItemDataService } from '../../../core/data/item-data.service';
import { RemoteData } from '../../../core/data/remote-data';
import { AbstractSimpleItemActionComponent } from './abstract-simple-item-action.component';
import { By } from '@angular/platform-browser';
import { of as observableOf } from 'rxjs';
import { getItemEditPath } from '../../item-page-routing.module';
import { RestResponse } from '../../../core/cache/response.models';
/**
* 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 {getSucceededRemoteData} from '../../../core/shared/operators';
import {first, map} from 'rxjs/operators';
import {RestResponse} from '../../../core/cache/response-cache.models';
import {findSuccessfulAccordingTo} from '../edit-item-operators';
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.

View File

@@ -6,7 +6,7 @@ import {
Subject,
Subscription
} 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 { Component, Inject, OnDestroy, OnInit } from '@angular/core';
import { Router } from '@angular/router';
@@ -126,7 +126,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
this.animationState = 'ready';
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))
}));
}));
@@ -189,7 +189,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
* @param data The string from the input field
*/
onSubmit(data: any) {
this.selectedValues.pipe(first()).subscribe((selectedValues) => {
this.selectedValues.pipe(take(1)).subscribe((selectedValues) => {
if (isNotEmpty(data)) {
this.router.navigate([this.getSearchLink()], {
queryParams:
@@ -258,7 +258,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy {
*/
findSuggestions(data): void {
if (isNotEmpty(data)) {
this.searchConfigService.searchOptions.pipe(first()).subscribe(
this.searchConfigService.searchOptions.pipe(take(1)).subscribe(
(options) => {
this.filterSearchResults = this.searchService.getFacetValuesFor(this.filterConfig, 1, options, data.toLowerCase())
.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 { SearchFilterConfig } from '../../search-service/search-filter-config.model';
import { SearchFilterService } from './search-filter.service';
@@ -37,7 +37,7 @@ export class SearchFilterComponent implements OnInit {
* Else, the filter should initially be collapsed
*/
ngOnInit() {
this.getSelectedValues().pipe(first()).subscribe((isActive) => {
this.getSelectedValues().pipe(take(1)).subscribe((isActive) => {
if (this.filter.isOpenByDefault || isNotEmpty(isActive)) {
this.initialExpand();
} else {

View File

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

View File

@@ -111,7 +111,6 @@ export const objects = [
id: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f',
uuid: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f',
type: ResourceType.Community,
name: 'OR2017 - Demonstration',
metadata: [
{
key: 'dc.description',
@@ -161,7 +160,6 @@ export const objects = [
id: '9076bd16-e69a-48d6-9e41-0238cb40d863',
uuid: '9076bd16-e69a-48d6-9e41-0238cb40d863',
type: ResourceType.Community,
name: 'Sample Community',
metadata: [
{
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 { ActivatedRoute, Router, UrlTree } from '@angular/router';
import { RequestService } from '../../core/data/request.service';
import { ResponseCacheService } from '../../core/cache/response-cache.service';
import { ActivatedRouteStub } from '../../shared/testing/active-router-stub';
import { RouterStub } from '../../shared/testing/router-stub';
import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
import { Observable, combineLatest as observableCombineLatest } from 'rxjs';
import { PaginatedSearchOptions } from '../paginated-search-options.model';
import { RemoteData } from '../../core/data/remote-data';
import { ResponseCacheEntry } from '../../core/cache/response-cache.reducer';
import { RequestEntry } from '../../core/data/request.reducer';
import { getMockRequestService } from '../../shared/mocks/mock-request.service';
import { getMockResponseCacheService } from '../../shared/mocks/mock-response-cache.service';
import {
FacetConfigSuccessResponse,
SearchSuccessResponse
} from '../../core/cache/response-cache.models';
} from '../../core/cache/response.models';
import { SearchQueryResponse } from './search-query-response.model';
import { SearchFilterConfig } from './search-filter-config.model';
import { CommunityDataService } from '../../core/data/community-data.service';
@@ -54,7 +51,6 @@ describe('SearchService', () => {
providers: [
{ provide: Router, useValue: router },
{ provide: ActivatedRoute, useValue: route },
{ provide: ResponseCacheService, useValue: getMockResponseCacheService() },
{ provide: RequestService, useValue: getMockRequestService() },
{ provide: RemoteDataBuildService, useValue: {} },
{ provide: HALEndpointService, useValue: {} },
@@ -86,11 +82,10 @@ describe('SearchService', () => {
};
const remoteDataBuildService = {
toRemoteDataObservable: (requestEntryObs: Observable<RequestEntry>, responseCacheObs: Observable<ResponseCacheEntry>, payloadObs: Observable<any>) => {
return observableCombineLatest(requestEntryObs,
responseCacheObs, payloadObs).pipe(
map(([req, res, pay]) => {
return { req, res, pay };
toRemoteDataObservable: (requestEntryObs: Observable<RequestEntry>, payloadObs: Observable<any>) => {
return observableCombineLatest(requestEntryObs, payloadObs).pipe(
map(([req, pay]) => {
return { req, pay };
})
);
},
@@ -113,7 +108,6 @@ describe('SearchService', () => {
providers: [
{ provide: Router, useValue: router },
{ provide: ActivatedRoute, useValue: route },
{ provide: ResponseCacheService, useValue: getMockResponseCacheService() },
{ provide: RequestService, useValue: getMockRequestService() },
{ provide: RemoteDataBuildService, useValue: remoteDataBuildService },
{ provide: HALEndpointService, useValue: halService },
@@ -162,10 +156,8 @@ describe('SearchService', () => {
const searchOptions = new PaginatedSearchOptions({});
const queryResponse = Object.assign(new SearchQueryResponse(), { objects: [] });
const response = new SearchSuccessResponse(queryResponse, '200');
const responseEntry = Object.assign(new ResponseCacheEntry(), { response: response });
beforeEach(() => {
spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(observableOf(endPoint));
(searchService as any).responseCache.get.and.returnValue(observableOf(responseEntry));
/* tslint:disable:no-empty */
searchService.search(searchOptions).subscribe((t) => {
}); // 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', () => {
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', () => {
const endPoint = 'http://endpoint.com/test/config';
const filterConfig = [new SearchFilterConfig()];
const response = new FacetConfigSuccessResponse(filterConfig, '200');
const responseEntry = Object.assign(new ResponseCacheEntry(), { response: response });
beforeEach(() => {
spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(observableOf(endPoint));
(searchService as any).responseCache.get.and.returnValue(observableOf(responseEntry));
/* tslint:disable:no-empty */
searchService.getConfig(null).subscribe((t) => {
}); // 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', () => {
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', () => {
@@ -224,10 +208,8 @@ describe('SearchService', () => {
const requestUrl = endPoint + '?scope=' + scope;
const filterConfig = [new SearchFilterConfig()];
const response = new FacetConfigSuccessResponse(filterConfig, '200');
const responseEntry = Object.assign(new ResponseCacheEntry(), { response: response });
beforeEach(() => {
spyOn((searchService as any).halService, 'getEndpoint').and.returnValue(observableOf(endPoint));
(searchService as any).responseCache.get.and.returnValue(observableOf(responseEntry));
/* tslint:disable:no-empty */
searchService.getConfig(scope).subscribe((t) => {
}); // 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', () => {
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 {
ActivatedRoute,
@@ -7,15 +7,13 @@ import {
Router,
UrlSegmentGroup
} 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 {
FacetConfigSuccessResponse,
FacetValueSuccessResponse,
SearchSuccessResponse
} from '../../core/cache/response-cache.models';
import { ResponseCacheEntry } from '../../core/cache/response-cache.reducer';
import { ResponseCacheService } from '../../core/cache/response-cache.service';
} from '../../core/cache/response.models';
import { PaginatedList } from '../../core/data/paginated-list';
import { ResponseParsingService } from '../../core/data/parsing.service';
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 { GenericConstructor } from '../../core/shared/generic-constructor';
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 { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util';
import { NormalizedSearchResult } from '../normalized-search-result.model';
@@ -68,7 +70,6 @@ export class SearchService implements OnDestroy {
constructor(private router: Router,
private route: ActivatedRoute,
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
private rdb: RemoteDataBuildService,
private halService: HALEndpointService,
@@ -98,16 +99,12 @@ export class SearchService implements OnDestroy {
configureRequest(this.requestService)
);
const requestEntryObs = requestObs.pipe(
flatMap((request: RestRequest) => this.requestService.getByHref(request.href))
);
const responseCacheObs = requestObs.pipe(
flatMap((request: RestRequest) => this.responseCache.get(request.href))
switchMap((request: RestRequest) => this.requestService.getByHref(request.href))
);
// get search results from response cache
const sqrObs: Observable<SearchQueryResponse> = responseCacheObs.pipe(
map((entry: ResponseCacheEntry) => entry.response),
const sqrObs: Observable<SearchQueryResponse> = requestEntryObs.pipe(
getResponseFromEntry(),
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
const dsoObs: Observable<RemoteData<DSpaceObject[]>> = sqrObs.pipe(
map((sqr: SearchQueryResponse) => {
return sqr.objects.map((nsr: NormalizedSearchResult) =>
this.rdb.buildSingle(nsr.dspaceObject));
return sqr.objects.map((nsr: NormalizedSearchResult) => {
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
@@ -139,8 +137,8 @@ export class SearchService implements OnDestroy {
})
);
const pageInfoObs: Observable<PageInfo> = responseCacheObs.pipe(
map((entry: ResponseCacheEntry) => entry.response),
const pageInfoObs: Observable<PageInfo> = requestEntryObs.pipe(
getResponseFromEntry(),
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(
flatMap((request: RestRequest) => this.requestService.getByHref(request.href))
);
const responseCacheObs = requestObs.pipe(
flatMap((request: RestRequest) => this.responseCache.get(request.href))
switchMap((request: RestRequest) => this.requestService.getByHref(request.href))
);
// get search results from response cache
const facetConfigObs: Observable<SearchFilterConfig[]> = responseCacheObs.pipe(
map((entry: ResponseCacheEntry) => entry.response),
const facetConfigObs: Observable<SearchFilterConfig[]> = requestEntryObs.pipe(
getResponseFromEntry(),
map((response: FacetConfigSuccessResponse) =>
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(
flatMap((request: RestRequest) => this.requestService.getByHref(request.href))
);
const responseCacheObs = requestObs.pipe(
flatMap((request: RestRequest) => this.responseCache.get(request.href))
switchMap((request: RestRequest) => this.requestService.getByHref(request.href))
);
// get search results from response cache
const facetValueObs: Observable<FacetValue[]> = responseCacheObs.pipe(
map((entry: ResponseCacheEntry) => entry.response),
const facetValueObs: Observable<FacetValue[]> = requestEntryObs.pipe(
getResponseFromEntry(),
map((response: FacetValueSuccessResponse) => response.results)
);
const pageInfoObs: Observable<PageInfo> = responseCacheObs.pipe(
map((entry: ResponseCacheEntry) => entry.response),
const pageInfoObs: Observable<PageInfo> = requestEntryObs.pipe(
getResponseFromEntry(),
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 {
AfterViewInit,
ChangeDetectionStrategy,
@@ -91,7 +91,7 @@ export class AppComponent implements OnInit, AfterViewInit {
// Whether is not authenticathed try to retrieve a possible stored auth token
this.store.pipe(select(isAuthenticated),
first(),
take(1),
filter((authenticated) => !authenticated)
).subscribe((authenticated) => this.authService.checkAuthenticationToken());
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 { Inject, Injectable } from '@angular/core';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { ResponseCacheService } from '../cache/response-cache.service';
import { RequestService } from '../data/request.service';
import { GLOBAL_CONFIG } from '../../../config';
import { GlobalConfig } from '../../../config/global-config.interface';
import { isNotEmpty } from '../../shared/empty.util';
import { AuthGetRequest, AuthPostRequest, PostRequest, RestRequest } from '../data/request.models';
import { ResponseCacheEntry } from '../cache/response-cache.reducer';
import { AuthStatusResponse, ErrorResponse } from '../cache/response-cache.models';
import { AuthStatusResponse, ErrorResponse } from '../cache/response.models';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { RequestEntry } from '../data/request.reducer';
import { getResponseFromEntry } from '../shared/operators';
@Injectable()
export class AuthRequestService {
@@ -19,15 +19,12 @@ export class AuthRequestService {
constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig,
protected halService: HALEndpointService,
protected responseCache: ResponseCacheService,
protected requestService: RequestService) {
}
protected fetchRequest(request: RestRequest): Observable<any> {
return this.responseCache.get(request.href).pipe(
map((entry: ResponseCacheEntry) => entry.response),
// TODO to review when https://github.com/DSpace/dspace-angular/issues/217 will be fixed
tap(() => this.responseCache.remove(request.href)),
return this.requestService.getByUUID(request.uuid).pipe(
getResponseFromEntry(),
mergeMap((response) => {
if (response.isSuccessful && isNotEmpty(response)) {
return observableOf((response as AuthStatusResponse).response);

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 { GlobalConfig } from '../../../config/global-config.interface';
import { AuthStatus } from './models/auth-status.model';
import { AuthResponseParsingService } from './auth-response-parsing.service';
import { AuthGetRequest, AuthPostRequest } from '../data/request.models';
@@ -11,7 +10,7 @@ import { ObjectCacheState } from '../cache/object-cache.reducer';
describe('AuthResponseParsingService', () => {
let service: AuthResponseParsingService;
const EnvConfig = { cache: { msToLive: 1000 } } as GlobalConfig;
const EnvConfig = { cache: { msToLive: 1000 } } as any;
const store = new MockStore<ObjectCacheState>({});
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 { 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 { GLOBAL_CONFIG } from '../../../config';
import { GlobalConfig } from '../../../config/global-config.interface';
@@ -27,7 +27,7 @@ export class AuthResponseParsingService extends BaseResponseParsingService imple
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
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);
} else {
return new AuthStatusResponse(data.payload as AuthStatus, data.statusCode);

View File

@@ -1,6 +1,6 @@
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 @ngrx
@@ -47,7 +47,7 @@ export class AuthEffects {
ofType(AuthActionTypes.AUTHENTICATE),
switchMap((action: AuthenticateAction) => {
return this.authService.authenticate(action.payload.email, action.payload.password).pipe(
first(),
take(1),
map((response: AuthStatus) => new AuthenticationSuccessAction(response.token)),
catchError((error) => observableOf(new AuthenticationErrorAction(error)))
);
@@ -127,7 +127,7 @@ export class AuthEffects {
switchMap(() => {
return this.store.pipe(
select(isAuthenticated),
first(),
take(1),
filter((authenticated) => !authenticated),
tap(() => this.authService.removeToken()),
tap(() => this.authService.resetAuthenticationError())

View File

@@ -9,10 +9,10 @@ import { of as observableOf } from 'rxjs';
import { AuthInterceptor } from './auth.interceptor';
import { AuthService } from './auth.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 { TruncatablesState } from '../../shared/truncatable/truncatable.reducer';
import { AuthServiceStub } from '../../shared/testing/auth-service-stub';
import { RestRequestMethod } from '../data/rest-request-method';
describe(`AuthInterceptor`, () => {
let service: DSpaceRESTv2Service;
@@ -49,7 +49,7 @@ describe(`AuthInterceptor`, () => {
describe('when has a valid token', () => {
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();
});
@@ -60,7 +60,7 @@ describe(`AuthInterceptor`, () => {
});
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();
});
@@ -85,11 +85,11 @@ describe(`AuthInterceptor`, () => {
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();
});
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');
});

View File

@@ -277,7 +277,7 @@ export class AuthService {
public isTokenExpiring(): Observable<boolean> {
return this.store.pipe(
select(isTokenRefreshing),
first(),
take(1),
map((isRefreshing: boolean) => {
if (this.isTokenExpired() || isRefreshing) {
return false;
@@ -360,7 +360,7 @@ export class AuthService {
*/
public redirectToPreviousUrl() {
this.getRedirectUrl().pipe(
first())
take(1))
.subscribe((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 { Observable } from 'rxjs';
@@ -45,7 +45,7 @@ export class ServerAuthService extends AuthService {
} else {
throw(new Error('Not authenticated'));
}
}))
}));
}
/**
@@ -60,7 +60,7 @@ export class ServerAuthService extends AuthService {
*/
public redirectToPreviousUrl() {
this.getRedirectUrl().pipe(
first())
take(1))
.subscribe((redirectUrl) => {
if (isNotEmpty(redirectUrl)) {
// override the route reuse strategy

View File

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

View File

@@ -11,16 +11,13 @@ import {
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { SortOptions } from '../cache/models/sort-options.model';
import { GenericSuccessResponse } from '../cache/response-cache.models';
import { ResponseCacheEntry } from '../cache/response-cache.reducer';
import { ResponseCacheService } from '../cache/response-cache.service';
import { GenericSuccessResponse } from '../cache/response.models';
import { PaginatedList } from '../data/paginated-list';
import { RemoteData } from '../data/remote-data';
import {
BrowseEndpointRequest,
BrowseEntriesRequest,
BrowseItemsRequest,
GetRequest,
RestRequest
} from '../data/request.models';
import { RequestService } from '../data/request.service';
@@ -29,10 +26,9 @@ import { BrowseEntry } from '../shared/browse-entry.model';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import {
configureRequest,
filterSuccessfulResponses, getBrowseDefinitionLinks,
getRemoteDataPayload,
getRequestFromSelflink,
getResponseFromSelflink
filterSuccessfulResponses,
getBrowseDefinitionLinks,
getRemoteDataPayload, getRequestFromRequestHref
} from '../shared/operators';
import { URLCombiner } from '../url-combiner/url-combiner';
import { Item } from '../shared/item.model';
@@ -57,7 +53,6 @@ export class BrowseService {
}
constructor(
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
protected halService: HALEndpointService,
private rdb: RemoteDataBuildService,
@@ -73,11 +68,9 @@ export class BrowseService {
);
const href$ = request$.pipe(map((request: RestRequest) => request.href));
const requestEntry$ = href$.pipe(getRequestFromSelflink(this.requestService));
const responseCache$ = href$.pipe(getResponseFromSelflink(this.responseCache));
const payload$ = responseCache$.pipe(
const requestEntry$ = href$.pipe(getRequestFromRequestHref(this.requestService));
const payload$ = requestEntry$.pipe(
filterSuccessfulResponses(),
map((entry: ResponseCacheEntry) => entry.response),
map((response: GenericSuccessResponse<BrowseDefinition[]>) => response.payload),
ensureArrayHasValue(),
map((definitions: BrowseDefinition[]) => definitions
@@ -85,7 +78,7 @@ export class BrowseService {
distinctUntilChanged()
);
return this.rdb.toRemoteDataObservable(requestEntry$, responseCache$, payload$);
return this.rdb.toRemoteDataObservable(requestEntry$, payload$);
}
getBrowseEntriesFor(options: BrowseEntrySearchOptions): Observable<RemoteData<PaginatedList<BrowseEntry>>> {
@@ -118,12 +111,10 @@ export class BrowseService {
const href$ = request$.pipe(map((request: RestRequest) => request.href));
const requestEntry$ = href$.pipe(getRequestFromSelflink(this.requestService));
const responseCache$ = href$.pipe(getResponseFromSelflink(this.responseCache));
const requestEntry$ = href$.pipe(getRequestFromRequestHref(this.requestService));
const payload$ = responseCache$.pipe(
const payload$ = requestEntry$.pipe(
filterSuccessfulResponses(),
map((entry: ResponseCacheEntry) => entry.response),
map((response: GenericSuccessResponse<BrowseEntry[]>) => new PaginatedList(response.pageInfo, response.payload)),
map((list: PaginatedList<BrowseEntry>) => Object.assign(list, {
page: list.page ? list.page.map((entry: BrowseEntry) => Object.assign(new BrowseEntry(), entry)) : list.page
@@ -131,7 +122,7 @@ export class BrowseService {
distinctUntilChanged()
);
return this.rdb.toRemoteDataObservable(requestEntry$, responseCache$, payload$);
return this.rdb.toRemoteDataObservable(requestEntry$, payload$);
}
/**
@@ -175,12 +166,10 @@ export class BrowseService {
const href$ = request$.pipe(map((request: RestRequest) => request.href));
const requestEntry$ = href$.pipe(getRequestFromSelflink(this.requestService));
const responseCache$ = href$.pipe(getResponseFromSelflink(this.responseCache));
const requestEntry$ = href$.pipe(getRequestFromRequestHref(this.requestService));
const payload$ = responseCache$.pipe(
const payload$ = requestEntry$.pipe(
filterSuccessfulResponses(),
map((entry: ResponseCacheEntry) => entry.response),
map((response: GenericSuccessResponse<Item[]>) => new PaginatedList(response.pageInfo, response.payload)),
map((list: PaginatedList<Item>) => Object.assign(list, {
page: list.page ? list.page.map((item: DSpaceObject) => Object.assign(new Item(), item)) : list.page
@@ -188,7 +177,7 @@ export class BrowseService {
distinctUntilChanged()
);
return this.rdb.toRemoteDataObservable(requestEntry$, responseCache$, payload$);
return this.rdb.toRemoteDataObservable(requestEntry$, payload$);
}
getBrowseURLFor(metadatumKey: string, linkPath: string): Observable<string> {

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;
beforeEach(() => {
service = new RemoteDataBuildService(undefined, undefined, undefined);
service = new RemoteDataBuildService(undefined, undefined);
});
describe('when toPaginatedList is called', () => {

View File

@@ -5,7 +5,15 @@ import {
race as observableRace
} from 'rxjs';
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 { PaginatedList } from '../../data/paginated-list';
import { RemoteData } from '../../data/remote-data';
@@ -16,22 +24,18 @@ import { RequestService } from '../../data/request.service';
import { NormalizedObject } from '../models/normalized-object.model';
import { ObjectCacheService } from '../object-cache.service';
import { DSOSuccessResponse, ErrorResponse } from '../response-cache.models';
import { ResponseCacheEntry } from '../response-cache.reducer';
import { ResponseCacheService } from '../response-cache.service';
import { DSOSuccessResponse, ErrorResponse } from '../response.models';
import { getMapsTo, getRelationMetadata, getRelationships } from './build-decorators';
import { PageInfo } from '../../shared/page-info.model';
import {
filterSuccessfulResponses,
getRequestFromSelflink,
getResourceLinksFromResponse,
getResponseFromSelflink
getRequestFromRequestHref, getRequestFromRequestUUID,
getResourceLinksFromResponse
} from '../../shared/operators';
@Injectable()
export class RemoteDataBuildService {
constructor(protected objectCache: ObjectCacheService,
protected responseCache: ResponseCacheService,
protected requestService: RequestService) {
}
@@ -39,29 +43,24 @@ export class RemoteDataBuildService {
if (typeof href$ === 'string') {
href$ = observableOf(href$);
}
const requestHref$ = href$.pipe(flatMap((href: string) =>
this.objectCache.getRequestHrefBySelfLink(href)));
const requestUUID$ = href$.pipe(
switchMap((href: string) =>
this.objectCache.getRequestUUIDBySelfLink(href)),
);
const requestEntry$ = observableRace(
href$.pipe(getRequestFromSelflink(this.requestService)),
requestHref$.pipe(getRequestFromSelflink(this.requestService))
href$.pipe(getRequestFromRequestHref(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.
const payload$ =
observableCombineLatest(
href$.pipe(
flatMap((href: string) => this.objectCache.getBySelfLink<TNormalized>(href)),
startWith(undefined)
),
responseCache$.pipe(
switchMap((href: string) => this.objectCache.getBySelfLink<TNormalized>(href)),
startWith(undefined)),
requestEntry$.pipe(
getResourceLinksFromResponse(),
flatMap((resourceSelfLinks: string[]) => {
switchMap((resourceSelfLinks: string[]) => {
if (isNotEmpty(resourceSelfLinks)) {
return this.objectCache.getBySelfLink(resourceSelfLinks[0]);
} else {
@@ -86,21 +85,21 @@ export class RemoteDataBuildService {
startWith(undefined),
distinctUntilChanged()
);
return this.toRemoteDataObservable(requestEntry$, responseCache$, payload$);
return this.toRemoteDataObservable(requestEntry$, payload$);
}
toRemoteDataObservable<T>(requestEntry$: Observable<RequestEntry>, responseCache$: Observable<ResponseCacheEntry>, payload$: Observable<T>) {
return observableCombineLatest(requestEntry$, responseCache$.pipe(startWith(undefined)), payload$).pipe(
map(([reqEntry, resEntry, payload]) => {
toRemoteDataObservable<T>(requestEntry$: Observable<RequestEntry>, payload$: Observable<T>) {
return observableCombineLatest(requestEntry$, payload$).pipe(
map(([reqEntry, payload]) => {
const requestPending = hasValue(reqEntry.requestPending) ? reqEntry.requestPending : true;
const responsePending = hasValue(reqEntry.responsePending) ? reqEntry.responsePending : false;
let isSuccessful: boolean;
let error: RemoteDataError;
if (hasValue(resEntry) && hasValue(resEntry.response)) {
isSuccessful = resEntry.response.isSuccessful;
const errorMessage = isSuccessful === false ? (resEntry.response as ErrorResponse).errorMessage : undefined;
if (hasValue(reqEntry) && hasValue(reqEntry.response)) {
isSuccessful = reqEntry.response.isSuccessful;
const errorMessage = isSuccessful === false ? (reqEntry.response as ErrorResponse).errorMessage : undefined;
if (hasValue(errorMessage)) {
error = new RemoteDataError(resEntry.response.statusCode, errorMessage);
error = new RemoteDataError(reqEntry.response.statusCode, errorMessage);
}
}
return new RemoteData(
@@ -119,10 +118,8 @@ export class RemoteDataBuildService {
href$ = observableOf(href$);
}
const requestEntry$ = href$.pipe(getRequestFromSelflink(this.requestService));
const responseCache$ = href$.pipe(getResponseFromSelflink(this.responseCache));
const tDomainList$ = responseCache$.pipe(
const requestEntry$ = href$.pipe(getRequestFromRequestHref(this.requestService));
const tDomainList$ = requestEntry$.pipe(
getResourceLinksFromResponse(),
flatMap((resourceUUIDs: string[]) => {
return this.objectCache.getList(resourceUUIDs).pipe(
@@ -133,14 +130,13 @@ export class RemoteDataBuildService {
}));
}),
startWith([]),
distinctUntilChanged()
distinctUntilChanged(),
);
const pageInfo$ = responseCache$.pipe(
const pageInfo$ = requestEntry$.pipe(
filterSuccessfulResponses(),
map((entry: ResponseCacheEntry) => {
if (hasValue((entry.response as DSOSuccessResponse).pageInfo)) {
const resPageInfo = (entry.response as DSOSuccessResponse).pageInfo;
map((response: DSOSuccessResponse) => {
if (hasValue((response as DSOSuccessResponse).pageInfo)) {
const resPageInfo = (response as DSOSuccessResponse).pageInfo;
if (isNotEmpty(resPageInfo) && resPageInfo.currentPage >= 0) {
return Object.assign({}, resPageInfo, { currentPage: resPageInfo.currentPage + 1 });
} 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 {
const links: any = {};
const relationships = getRelationships(normalized.constructor) || [];
relationships.forEach((relationship: string) => {
@@ -204,7 +199,6 @@ export class RemoteDataBuildService {
}
}
});
const domainModel = getMapsTo(normalized.constructor);
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 { Community } from '../../shared/community.model';
@@ -21,32 +21,32 @@ export class NormalizedCommunity extends NormalizedDSpaceObject {
/**
* The Bitstream that represents the logo of this Community
*/
@autoserialize
@deserialize
@relationship(ResourceType.Bitstream, false)
logo: string;
/**
* An array of Communities that are direct parents of this Community
*/
@autoserialize
@deserialize
@relationship(ResourceType.Community, true)
parents: string[];
/**
* The Community that owns this Community
*/
@autoserialize
@deserialize
@relationship(ResourceType.Community, false)
owner: string;
/**
* List of Collections that are owned by this Community
*/
@autoserialize
@deserialize
@relationship(ResourceType.Collection, true)
collections: string[];
@autoserialize
@deserialize
@relationship(ResourceType.Community, true)
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 { Metadatum } from '../../shared/metadatum.model';
@@ -18,7 +18,7 @@ export class NormalizedDSpaceObject extends NormalizedObject {
* Repeated here to make the serialization work,
* inheritSerialization doesn't seem to work for more than one level
*/
@autoserialize
@deserialize
self: string;
/**
@@ -45,12 +45,6 @@ export class NormalizedDSpaceObject extends NormalizedObject {
@autoserialize
type: ResourceType;
/**
* The name for this DSpaceObject
*/
@autoserialize
name: string;
/**
* 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
*/
@autoserialize
@deserialize
parents: string[];
/**
* The DSpaceObject that owns this DSpaceObject
*/
@autoserialize
@deserialize
owner: string;
/**
@@ -75,7 +69,7 @@ export class NormalizedDSpaceObject extends NormalizedObject {
* Repeated here to make the serialization work,
* inheritSerialization doesn't seem to work for more than one level
*/
@autoserialize
@deserialize
_links: {
[name: string]: string
}

View File

@@ -2,6 +2,7 @@ import { Action } from '@ngrx/store';
import { type } from '../../shared/ngrx/type';
import { CacheableObject } from './object-cache.reducer';
import { Operation } from 'fast-json-patch';
/**
* The list of ObjectCacheAction type definitions
@@ -9,7 +10,9 @@ import { CacheableObject } from './object-cache.reducer';
export const ObjectCacheActionTypes = {
ADD: type('dspace/core/cache/object/ADD'),
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 */
@@ -22,7 +25,7 @@ export class AddToObjectCacheAction implements Action {
objectToCache: CacheableObject;
timeAdded: 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
* link, it could have been part of a list for example
*/
constructor(objectToCache: CacheableObject, timeAdded: number, msToLive: number, requestHref: string) {
this.payload = { objectToCache, timeAdded, msToLive, requestHref };
constructor(objectToCache: CacheableObject, timeAdded: number, msToLive: number, requestUUID: string) {
this.payload = { objectToCache, timeAdded, msToLive, requestUUID };
}
}
@@ -54,11 +57,11 @@ export class RemoveFromObjectCacheAction implements Action {
/**
* Create a new RemoveFromObjectCacheAction
*
* @param uuid
* the UUID of the object to remove
* @param href
* the unique href of the object to remove
*/
constructor(uuid: string) {
this.payload = uuid;
constructor(href: string) {
this.payload = href;
}
}
@@ -79,6 +82,48 @@ export class ResetObjectCacheTimestampsAction implements Action {
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 */
/**
@@ -87,4 +132,6 @@ export class ResetObjectCacheTimestampsAction implements Action {
export type ObjectCacheAction
= AddToObjectCacheAction
| RemoveFromObjectCacheAction
| ResetObjectCacheTimestampsAction;
| ResetObjectCacheTimestampsAction
| AddPatchObjectCacheAction
| ApplyPatchObjectCacheAction;

View File

@@ -2,9 +2,13 @@ import * as deepFreeze from 'deep-freeze';
import { objectCacheReducer } from './object-cache.reducer';
import {
AddPatchObjectCacheAction,
AddToObjectCacheAction,
RemoveFromObjectCacheAction, ResetObjectCacheTimestampsAction
ApplyPatchObjectCacheAction,
RemoveFromObjectCacheAction,
ResetObjectCacheTimestampsAction
} from './object-cache.actions';
import { Operation } from 'fast-json-patch';
class NullAction extends RemoveFromObjectCacheAction {
type = null;
@@ -16,8 +20,11 @@ class NullAction extends RemoveFromObjectCacheAction {
}
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 selfLink2 = 'https://localhost:8080/api/core/items/28b04544-1766-4e82-9728-c4e93544ecd3';
const newName = 'new different name';
const testState = {
[selfLink1]: {
data: {
@@ -26,16 +33,20 @@ describe('objectCacheReducer', () => {
},
timeAdded: new Date().getTime(),
msToLive: 900000,
requestHref: selfLink1
requestUUID: requestUUID1,
patches: [],
isDirty: false
},
[selfLink2]: {
data: {
self: selfLink2,
self: requestUUID2,
foo: 'baz'
},
timeAdded: new Date().getTime(),
msToLive: 900000,
requestHref: selfLink2
requestUUID: selfLink2,
patches: [],
isDirty: false
}
};
deepFreeze(testState);
@@ -59,8 +70,8 @@ describe('objectCacheReducer', () => {
const objectToCache = { self: selfLink1 };
const timeAdded = new Date().getTime();
const msToLive = 900000;
const requestHref = 'https://rest.api/endpoint/selfLink1';
const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive, requestHref);
const requestUUID = requestUUID1;
const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive, requestUUID);
const newState = objectCacheReducer(state, action);
expect(newState[selfLink1].data).toEqual(objectToCache);
@@ -72,8 +83,8 @@ describe('objectCacheReducer', () => {
const objectToCache = { self: selfLink1, foo: 'baz', somethingElse: true };
const timeAdded = new Date().getTime();
const msToLive = 900000;
const requestHref = 'https://rest.api/endpoint/selfLink1';
const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive, requestHref);
const requestUUID = requestUUID1;
const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive, requestUUID);
const newState = objectCacheReducer(testState, action);
/* tslint:disable:no-string-literal */
@@ -87,8 +98,8 @@ describe('objectCacheReducer', () => {
const objectToCache = { self: selfLink1 };
const timeAdded = new Date().getTime();
const msToLive = 900000;
const requestHref = 'https://rest.api/endpoint/selfLink1';
const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive, requestHref);
const requestUUID = requestUUID1;
const action = new AddToObjectCacheAction(objectToCache, timeAdded, msToLive, requestUUID);
deepFreeze(state);
objectCacheReducer(state, action);
@@ -132,4 +143,32 @@ describe('objectCacheReducer', () => {
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 {
ObjectCacheAction, ObjectCacheActionTypes, AddToObjectCacheAction,
RemoveFromObjectCacheAction, ResetObjectCacheTimestampsAction
ObjectCacheAction,
ObjectCacheActionTypes,
AddToObjectCacheAction,
RemoveFromObjectCacheAction,
ResetObjectCacheTimestampsAction,
AddPatchObjectCacheAction, ApplyPatchObjectCacheAction
} from './object-cache.actions';
import { hasValue } from '../../shared/empty.util';
import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { CacheEntry } from './cache-entry';
import { ResourceType } from '../shared/resource-type';
import { applyPatch, Operation } from 'fast-json-patch';
export enum DirtyType {
Created = 'Created',
@@ -12,6 +17,21 @@ export enum DirtyType {
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
*
@@ -35,7 +55,9 @@ export class ObjectCacheEntry implements CacheEntry {
data: CacheableObject;
timeAdded: 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)
}
case ObjectCacheActionTypes.ADD_PATCH: {
return addPatchObjectCache(state, action as AddPatchObjectCacheAction);
}
case ObjectCacheActionTypes.APPLY_PATCH: {
return applyPatchObjectCache(state, action as ApplyPatchObjectCacheAction);
}
default: {
return state;
}
@@ -93,12 +123,15 @@ export function objectCacheReducer(state = initialState, action: ObjectCacheActi
* the new state, with the object added, or overwritten.
*/
function addToObjectCache(state: ObjectCacheState, action: AddToObjectCacheAction): ObjectCacheState {
const existing = state[action.payload.objectToCache.self];
return Object.assign({}, state, {
[action.payload.objectToCache.self]: {
data: action.payload.objectToCache,
timeAdded: action.payload.timeAdded,
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;
}
/**
* 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 { 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 { ResourceType } from '../shared/resource-type';
import { NormalizedItem } from './models/normalized-item.model';
import { first } from 'rxjs/operators';
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', () => {
let service: ObjectCacheService;
let store: Store<CoreState>;
const selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7';
const requestUUID = '4d3a4ce8-a375-4b98-859b-39f0a014d736';
const timestamp = new Date().getTime();
const msToLive = 900000;
const objectToCache = {
let objectToCache = {
self: selfLink,
type: ResourceType.Item
};
const cacheEntry = {
let cacheEntry;
let invalidCacheEntry;
const operations = [{ op: 'replace', path: '/name', value: 'random string' } as Operation];
function init() {
objectToCache = {
self: selfLink,
type: ResourceType.Item
};
cacheEntry = {
data: objectToCache,
timeAdded: timestamp,
msToLive: msToLive
};
const invalidCacheEntry = Object.assign({}, cacheEntry, { msToLive: -1 });
invalidCacheEntry = Object.assign({}, cacheEntry, { msToLive: -1 })
}
beforeEach(() => {
init();
store = new Store<CoreState>(undefined, undefined, undefined);
spyOn(store, 'dispatch');
service = new ObjectCacheService(store);
@@ -39,8 +59,8 @@ describe('ObjectCacheService', () => {
describe('add', () => {
it('should dispatch an ADD action with the object to add, the time to live, and the current timestamp', () => {
service.add(objectToCache, msToLive, selfLink);
expect(store.dispatch).toHaveBeenCalledWith(new AddToObjectCacheAction(objectToCache, timestamp, msToLive, selfLink));
service.add(objectToCache, msToLive, requestUUID);
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 { distinctUntilChanged, filter, first, map, mergeMap, take } from 'rxjs/operators';
import { distinctUntilChanged, filter, map, mergeMap, take, } from 'rxjs/operators';
import { Injectable } from '@angular/core';
import { MemoizedSelector, select, Store } from '@ngrx/store';
import { IndexName } from '../index/index.reducer';
import { CacheableObject, ObjectCacheEntry } from './object-cache.reducer';
import { AddToObjectCacheAction, RemoveFromObjectCacheAction } from './object-cache.actions';
import { hasNoValue } from '../../shared/empty.util';
import {
AddPatchObjectCacheAction,
AddToObjectCacheAction,
ApplyPatchObjectCacheAction,
RemoveFromObjectCacheAction
} from './object-cache.actions';
import { hasNoValue, isNotEmpty } from '../../shared/empty.util';
import { GenericConstructor } from '../shared/generic-constructor';
import { coreSelector, CoreState } from '../core.reducers';
import { pathSelector } from '../shared/selectors';
import { NormalizedObjectFactory } from './models/normalized-object-factory';
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> {
return pathSelector<CoreState, string>(coreSelector, 'index', IndexName.OBJECT, uuid);
}
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
* @param msToLive
* The number of milliseconds it should be cached for
* @param requestHref
* The selfLink 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
* @param requestUUID
* The UUID of the request that resulted in this object
*/
add(objectToCache: CacheableObject, msToLive: number, requestHref: string): void {
this.store.dispatch(new AddToObjectCacheAction(objectToCache, new Date().getTime(), msToLive, requestHref));
add(objectToCache: CacheableObject, msToLive: number, requestUUID: string): void {
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
* The UUID of the object to be removed
* @param href
* The unique href of the object to be removed
*/
remove(uuid: string): void {
this.store.dispatch(new RemoveFromObjectCacheAction(uuid));
@@ -82,10 +88,21 @@ export class ObjectCacheService {
getBySelfLink<T extends NormalizedObject>(selfLink: string): Observable<T> {
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) => {
const type: GenericConstructor<NormalizedObject> = NormalizedObjectFactory.getConstructor(entry.data.type);
return Object.assign(new type(), entry.data) as T
}));
})
);
}
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(
map((entry: ObjectCacheEntry) => entry.requestHref),
distinctUntilChanged(),);
map((entry: ObjectCacheEntry) => entry.requestUUID),
distinctUntilChanged());
}
getRequestHrefByUUID(uuid: string): Observable<string> {
getRequestUUIDByObjectUUID(uuid: string): Observable<string> {
return this.store.pipe(
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(
select(selfLinkFromUuidSelector(uuid)),
first()
take(1)
).subscribe((selfLink: string) => result = this.hasBySelfLink(selfLink));
return result;
@@ -167,7 +184,7 @@ export class ObjectCacheService {
let result = false;
this.store.pipe(select(entryFromSelfLinkSelector(selfLink)),
first()
take(1)
).subscribe((entry: ObjectCacheEntry) => result = this.isValid(entry));
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 { RegistryBitstreamformatsResponse } from '../registry/registry-bitstreamformats-response.model';
import { AuthStatus } from '../auth/models/auth-status.model';
import { DSpaceObject } from '../shared/dspace-object.model';
/* tslint:disable:max-classes-per-file */
export class RestResponse {
public toCache = true;
public timeAdded: number;
constructor(
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 { TestScheduler } from 'rxjs/testing';
import { getMockRequestService } from '../../shared/mocks/mock-request.service';
import { ResponseCacheService } from '../cache/response-cache.service';
import { ConfigService } from './config.service';
import { RequestService } from '../data/request.service';
import { ConfigRequest, FindAllOptions } from '../data/request.models';
@@ -16,7 +15,6 @@ class TestService extends ConfigService {
protected browseEndpoint = BROWSE;
constructor(
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
protected halService: HALEndpointService) {
super();
@@ -26,7 +24,6 @@ class TestService extends ConfigService {
describe('ConfigService', () => {
let scheduler: TestScheduler;
let service: TestService;
let responseCache: ResponseCacheService;
let requestService: RequestService;
let halService: any;
@@ -39,17 +36,8 @@ describe('ConfigService', () => {
const scopedEndpoint = `${serviceEndpoint}/${scopeName}`;
const searchEndpoint = `${serviceEndpoint}/${BROWSE}?uuid=${scopeID}`;
function initMockResponseCacheService(isSuccessful: boolean): ResponseCacheService {
return jasmine.createSpyObj('responseCache', {
get: cold('c-', {
c: { response: { isSuccessful } }
})
});
}
function initTestService(): TestService {
return new TestService(
responseCache,
requestService,
halService
);
@@ -57,7 +45,6 @@ describe('ConfigService', () => {
beforeEach(() => {
scheduler = getTestScheduler();
responseCache = initMockResponseCacheService(true);
requestService = getMockRequestService();
halService = new HALEndpointServiceStub(configEndpoint);
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 { RequestService } from '../data/request.service';
import { ResponseCacheService } from '../cache/response-cache.service';
import { ConfigSuccessResponse } from '../cache/response-cache.models';
import { ConfigSuccessResponse } from '../cache/response.models';
import { ConfigRequest, FindAllOptions, RestRequest } from '../data/request.models';
import { ResponseCacheEntry } from '../cache/response-cache.reducer';
import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { ConfigData } from './config-data';
import { RequestEntry } from '../data/request.reducer';
import { getResponseFromEntry } from '../shared/operators';
export abstract class ConfigService {
protected request: ConfigRequest;
protected abstract responseCache: ResponseCacheService;
protected abstract requestService: RequestService;
protected abstract linkPath: string;
protected abstract browseEndpoint: string;
protected abstract halService: HALEndpointService;
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(
filter((response) => !response.isSuccessful),
mergeMap(() => observableThrowError(new Error(`Couldn't retrieve the config`)))
@@ -94,7 +95,6 @@ export abstract class ConfigService {
}
public getConfigBySearch(options: FindAllOptions = {}): Observable<ConfigData> {
console.log(this.halService.getEndpoint(this.linkPath));
return this.halService.getEndpoint(this.linkPath).pipe(
map((endpoint: string) => this.getConfigSearchHref(endpoint, options)),
filter((href: string) => isNotEmpty(href)),

View File

@@ -1,7 +1,6 @@
import { Injectable } from '@angular/core';
import { ConfigService } from './config.service';
import { ResponseCacheService } from '../cache/response-cache.service';
import { RequestService } from '../data/request.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
@@ -11,7 +10,6 @@ export class SubmissionDefinitionsConfigService extends ConfigService {
protected browseEndpoint = 'search/findByCollection';
constructor(
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
protected halService: HALEndpointService) {
super();

View File

@@ -1,7 +1,6 @@
import { Injectable } from '@angular/core';
import { ConfigService } from './config.service';
import { ResponseCacheService } from '../cache/response-cache.service';
import { RequestService } from '../data/request.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
@@ -11,7 +10,6 @@ export class SubmissionFormsConfigService extends ConfigService {
protected browseEndpoint = '';
constructor(
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
protected halService: HALEndpointService) {
super();

View File

@@ -1,7 +1,6 @@
import { Injectable } from '@angular/core';
import { ConfigService } from './config.service';
import { ResponseCacheService } from '../cache/response-cache.service';
import { RequestService } from '../data/request.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
@@ -11,7 +10,6 @@ export class SubmissionSectionsConfigService extends ConfigService {
protected browseEndpoint = '';
constructor(
protected responseCache: ResponseCacheService,
protected requestService: RequestService,
protected halService: HALEndpointService) {
super();

View File

@@ -1,14 +1,14 @@
import { ObjectCacheEffects } from './cache/object-cache.effects';
import { ResponseCacheEffects } from './cache/response-cache.effects';
import { UUIDIndexEffects } from './index/index.effects';
import { RequestEffects } from './data/request.effects';
import { AuthEffects } from './auth/auth.effects';
import { ServerSyncBufferEffects } from './cache/server-sync-buffer.effects';
export const coreEffects = [
ResponseCacheEffects,
RequestEffects,
ObjectCacheEffects,
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 { RemoteDataBuildService } from './cache/builders/remote-data-build.service';
import { RequestService } from './data/request.service';
import { ResponseCacheService } from './cache/response-cache.service';
import { EndpointMapResponseParsingService } from './data/endpoint-map-response-parsing.service';
import { ServerResponseService } from '../shared/services/server-response.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 { CSSVariableService } from '../shared/sass-helper/sass-helper.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 = [
CommonModule,
@@ -102,9 +103,9 @@ const PROVIDERS = [
ObjectCacheService,
PaginationComponentOptions,
RegistryService,
NormalizedObjectBuildService,
RemoteDataBuildService,
RequestService,
ResponseCacheService,
EndpointMapResponseParsingService,
FacetValueResponseParsingService,
FacetValueMapResponseParsingService,
@@ -130,6 +131,7 @@ const PROVIDERS = [
UploaderService,
UUIDService,
DSpaceObjectDataService,
DSOChangeAnalyzer,
CSSVariableService,
MenuService,
// register AuthInterceptor as HttpInterceptor

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