Merge branch 'main' into new-themes

This commit is contained in:
Michael Spalti
2022-09-20 15:19:54 -07:00
381 changed files with 37728 additions and 13451 deletions

16
.gitattributes vendored
View File

@@ -1,2 +1,16 @@
# Auto detect text files and perform LF normalization # By default, auto detect text files and perform LF normalization
# This ensures code is always checked in with LF line endings
* text=auto * text=auto
# JS and TS files must always use LF for Angular tools to work
# Some Angular tools expect LF line endings, even on Windows.
# This ensures Windows always checks out these files with LF line endings
# We've copied many of these rules from https://github.com/angular/angular-cli/
*.js eol=lf
*.ts eol=lf
*.json eol=lf
*.json5 eol=lf
*.css eol=lf
*.scss eol=lf
*.html eol=lf
*.svg eol=lf

View File

@@ -179,7 +179,7 @@ If needing to update default configurations values for production, update local
- Update `environment.production.ts` file in `src/environment/` for a `production` environment; - Update `environment.production.ts` file in `src/environment/` for a `production` environment;
The environment object is provided for use as import in code and is extended with he runtime configuration on bootstrap of the application. The environment object is provided for use as import in code and is extended with the runtime configuration on bootstrap of the application.
> Take caution moving runtime configs into the buildtime configuration. They will be overwritten by what is defined in the runtime config on bootstrap. > Take caution moving runtime configs into the buildtime configuration. They will be overwritten by what is defined in the runtime config on bootstrap.

View File

@@ -150,12 +150,21 @@ languages:
- code: fi - code: fi
label: Suomi label: Suomi
active: true active: true
- code: sv
label: Svenska
active: true
- code: tr - code: tr
label: Türkçe label: Türkçe
active: true active: true
- code: kk
label: Қазақ
active: true
- code: bn - code: bn
label: বাংলা label: বাংলা
active: true active: true
- code: el
label: Ελληνικά
active: true
# Browse-By Pages # Browse-By Pages
browseBy: browseBy:
@@ -166,6 +175,21 @@ browseBy:
# The absolute lowest year to display in the dropdown (only used when no lowest date can be found for all items) # The absolute lowest year to display in the dropdown (only used when no lowest date can be found for all items)
defaultLowerLimit: 1900 defaultLowerLimit: 1900
communityList:
# No. of communities to list per expansion (show more)
pageSize: 20
homePage:
recentSubmissions:
# The number of item showing in recent submission components
pageSize: 5
# Sort record of recent submission
sortField: 'dc.date.accessioned'
topLevelCommunityList:
# No. of communities to list per page on the home page
# This will always round to the nearest number from the list of page sizes. e.g. if you set it to 7 it'll use 10
pageSize: 5
# Item Config # Item Config
item: item:
edit: edit:
@@ -248,3 +272,10 @@ bundle:
mediaViewer: mediaViewer:
image: false image: false
video: false video: false
# Whether the end user agreement is required before users use the repository.
# If enabled, the user will be required to accept the agreement before they can use the repository.
# And whether the privacy statement should exist or not.
info:
enableEndUserAgreement: true
enablePrivacyStatement: true

1
cypress/.gitignore vendored
View File

@@ -1,2 +1,3 @@
screenshots/ screenshots/
videos/ videos/
downloads/

View File

@@ -1,6 +1,6 @@
{ {
"name": "dspace-angular", "name": "dspace-angular",
"version": "0.0.0", "version": "7.4.0-next",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"config:watch": "nodemon", "config:watch": "nodemon",
@@ -10,7 +10,7 @@
"start:prod": "yarn run build:prod && cross-env NODE_ENV=production yarn run serve:ssr", "start:prod": "yarn run build:prod && cross-env NODE_ENV=production yarn run serve:ssr",
"start:mirador:prod": "yarn run build:mirador && yarn run start:prod", "start:mirador:prod": "yarn run build:mirador && yarn run start:prod",
"preserve": "yarn base-href", "preserve": "yarn base-href",
"serve": "ng serve --configuration development", "serve": "ts-node --project ./tsconfig.ts-node.json scripts/serve.ts",
"serve:ssr": "node dist/server/main", "serve:ssr": "node dist/server/main",
"analyze": "webpack-bundle-analyzer dist/browser/stats.json", "analyze": "webpack-bundle-analyzer dist/browser/stats.json",
"build": "ng build --configuration development", "build": "ng build --configuration development",
@@ -107,7 +107,7 @@
"mirador": "^3.3.0", "mirador": "^3.3.0",
"mirador-dl-plugin": "^0.13.0", "mirador-dl-plugin": "^0.13.0",
"mirador-share-plugin": "^0.11.0", "mirador-share-plugin": "^0.11.0",
"moment": "^2.29.2", "moment": "^2.29.4",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"ng-mocks": "^13.1.1", "ng-mocks": "^13.1.1",
"ng2-file-upload": "1.4.0", "ng2-file-upload": "1.4.0",

View File

@@ -7,8 +7,9 @@ const appConfig: AppConfig = buildAppConfig();
/** /**
* Calls `ng serve` with the following arguments configured for the UI in the app config: host, port, nameSpace, ssl * Calls `ng serve` with the following arguments configured for the UI in the app config: host, port, nameSpace, ssl
* Any CLI arguments given to this script are patched through to `ng serve` as well.
*/ */
child.spawn( child.spawn(
`ng serve --host ${appConfig.ui.host} --port ${appConfig.ui.port} --serve-path ${appConfig.ui.nameSpace} --ssl ${appConfig.ui.ssl}`, `ng serve --host ${appConfig.ui.host} --port ${appConfig.ui.port} --serve-path ${appConfig.ui.nameSpace} --ssl ${appConfig.ui.ssl} ${process.argv.slice(2).join(' ')} --configuration development`,
{ stdio: 'inherit', shell: true } { stdio: 'inherit', shell: true }
); );

0
scripts/sync-i18n-files.ts Executable file → Normal file
View File

View File

@@ -22,7 +22,13 @@ console.log(`...Testing connection to REST API at ${restUrl}...\n`);
if (appConfig.rest.ssl) { if (appConfig.rest.ssl) {
const req = https.request(restUrl, (res) => { const req = https.request(restUrl, (res) => {
console.log(`RESPONSE: ${res.statusCode} ${res.statusMessage} \n`); console.log(`RESPONSE: ${res.statusCode} ${res.statusMessage} \n`);
res.on('data', (data) => { // We will keep reading data until the 'end' event fires.
// This ensures we don't just read the first chunk.
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
checkJSONResponse(data); checkJSONResponse(data);
}); });
}); });
@@ -35,7 +41,13 @@ if (appConfig.rest.ssl) {
} else { } else {
const req = http.request(restUrl, (res) => { const req = http.request(restUrl, (res) => {
console.log(`RESPONSE: ${res.statusCode} ${res.statusMessage} \n`); console.log(`RESPONSE: ${res.statusCode} ${res.statusMessage} \n`);
res.on('data', (data) => { // We will keep reading data until the 'end' event fires.
// This ensures we don't just read the first chunk.
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
checkJSONResponse(data); checkJSONResponse(data);
}); });
}); });

View File

@@ -48,6 +48,7 @@ import { ServerAppModule } from './src/main.server';
import { buildAppConfig } from './src/config/config.server'; import { buildAppConfig } from './src/config/config.server';
import { APP_CONFIG, AppConfig } from './src/config/app-config.interface'; import { APP_CONFIG, AppConfig } from './src/config/app-config.interface';
import { extendEnvironmentWithAppConfig } from './src/config/config.util'; import { extendEnvironmentWithAppConfig } from './src/config/config.util';
import { logStartupMessage } from './startup-message';
/* /*
* Set path for the browser application's dist folder * Set path for the browser application's dist folder
@@ -281,6 +282,8 @@ function run() {
} }
function start() { function start() {
logStartupMessage(environment);
/* /*
* If SSL is enabled * If SSL is enabled
* - Read credentials from configuration files * - Read credentials from configuration files

View File

@@ -177,7 +177,7 @@ describe('EPersonFormComponent', () => {
}); });
groupsDataService = jasmine.createSpyObj('groupsDataService', { groupsDataService = jasmine.createSpyObj('groupsDataService', {
findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])),
getGroupRegistryRouterLink: '' getGroupRegistryRouterLink: ''
}); });

View File

@@ -265,7 +265,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
this.formGroup = this.formBuilderService.createFormGroup(this.formModel); this.formGroup = this.formBuilderService.createFormGroup(this.formModel);
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
if (eperson != null) { if (eperson != null) {
this.groups = this.groupsDataService.findAllByHref(eperson._links.groups.href, { this.groups = this.groupsDataService.findListByHref(eperson._links.groups.href, {
currentPage: 1, currentPage: 1,
elementsPerPage: this.config.pageSize elementsPerPage: this.config.pageSize
}); });
@@ -297,7 +297,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
}), }),
switchMap(([eperson, findListOptions]) => { switchMap(([eperson, findListOptions]) => {
if (eperson != null) { if (eperson != null) {
return this.groupsDataService.findAllByHref(eperson._links.groups.href, findListOptions, true, true, followLink('object')); return this.groupsDataService.findListByHref(eperson._links.groups.href, findListOptions, true, true, followLink('object'));
} }
return observableOf(undefined); return observableOf(undefined);
}) })
@@ -554,7 +554,7 @@ export class EPersonFormComponent implements OnInit, OnDestroy {
*/ */
private updateGroups(options) { private updateGroups(options) {
this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => { this.subs.push(this.epersonService.getActiveEPerson().subscribe((eperson: EPerson) => {
this.groups = this.groupsDataService.findAllByHref(eperson._links.groups.href, options); this.groups = this.groupsDataService.findListByHref(eperson._links.groups.href, options);
})); }));
} }
} }

View File

@@ -53,7 +53,7 @@ describe('MembersListComponent', () => {
activeGroup: activeGroup, activeGroup: activeGroup,
epersonMembers: epersonMembers, epersonMembers: epersonMembers,
subgroupMembers: subgroupMembers, subgroupMembers: subgroupMembers,
findAllByHref(href: string): Observable<RemoteData<PaginatedList<EPerson>>> { findListByHref(href: string): Observable<RemoteData<PaginatedList<EPerson>>> {
return createSuccessfulRemoteDataObject$(buildPaginatedList<EPerson>(new PageInfo(), groupsDataServiceStub.getEPersonMembers())); return createSuccessfulRemoteDataObject$(buildPaginatedList<EPerson>(new PageInfo(), groupsDataServiceStub.getEPersonMembers()));
}, },
searchByScope(scope: string, query: string): Observable<RemoteData<PaginatedList<EPerson>>> { searchByScope(scope: string, query: string): Observable<RemoteData<PaginatedList<EPerson>>> {

View File

@@ -129,7 +129,7 @@ export class MembersListComponent implements OnInit, OnDestroy {
this.subs.set(SubKey.MembersDTO, this.subs.set(SubKey.MembersDTO,
this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( this.paginationService.getCurrentPagination(this.config.id, this.config).pipe(
switchMap((currentPagination) => { switchMap((currentPagination) => {
return this.ePersonDataService.findAllByHref(this.groupBeingEdited._links.epersons.href, { return this.ePersonDataService.findListByHref(this.groupBeingEdited._links.epersons.href, {
currentPage: currentPagination.currentPage, currentPage: currentPagination.currentPage,
elementsPerPage: currentPagination.pageSize elementsPerPage: currentPagination.pageSize
} }
@@ -171,7 +171,7 @@ export class MembersListComponent implements OnInit, OnDestroy {
return this.groupDataService.getActiveGroup().pipe(take(1), return this.groupDataService.getActiveGroup().pipe(take(1),
mergeMap((group: Group) => { mergeMap((group: Group) => {
if (group != null) { if (group != null) {
return this.ePersonDataService.findAllByHref(group._links.epersons.href, { return this.ePersonDataService.findListByHref(group._links.epersons.href, {
currentPage: 1, currentPage: 1,
elementsPerPage: 9999 elementsPerPage: 9999
}, false) }, false)

View File

@@ -65,7 +65,7 @@ describe('SubgroupsListComponent', () => {
getSubgroups(): Group { getSubgroups(): Group {
return this.activeGroup; return this.activeGroup;
}, },
findAllByHref(href: string): Observable<RemoteData<PaginatedList<Group>>> { findListByHref(href: string): Observable<RemoteData<PaginatedList<Group>>> {
return this.subgroups$.pipe( return this.subgroups$.pipe(
map((currentGroups: Group[]) => { map((currentGroups: Group[]) => {
return createSuccessfulRemoteDataObject(buildPaginatedList<Group>(new PageInfo(), currentGroups)); return createSuccessfulRemoteDataObject(buildPaginatedList<Group>(new PageInfo(), currentGroups));

View File

@@ -115,7 +115,7 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
this.subs.set( this.subs.set(
SubKey.Members, SubKey.Members,
this.paginationService.getCurrentPagination(this.config.id, this.config).pipe( this.paginationService.getCurrentPagination(this.config.id, this.config).pipe(
switchMap((config) => this.groupDataService.findAllByHref(this.groupBeingEdited._links.subgroups.href, { switchMap((config) => this.groupDataService.findListByHref(this.groupBeingEdited._links.subgroups.href, {
currentPage: config.currentPage, currentPage: config.currentPage,
elementsPerPage: config.pageSize elementsPerPage: config.pageSize
}, },
@@ -139,7 +139,7 @@ export class SubgroupsListComponent implements OnInit, OnDestroy {
if (activeGroup.uuid === possibleSubgroup.uuid) { if (activeGroup.uuid === possibleSubgroup.uuid) {
return observableOf(false); return observableOf(false);
} else { } else {
return this.groupDataService.findAllByHref(activeGroup._links.subgroups.href, { return this.groupDataService.findListByHref(activeGroup._links.subgroups.href, {
currentPage: 1, currentPage: 1,
elementsPerPage: 9999 elementsPerPage: 9999
}) })

View File

@@ -69,7 +69,7 @@ describe('GroupRegistryComponent', () => {
mockGroups = [GroupMock, GroupMock2]; mockGroups = [GroupMock, GroupMock2];
mockEPeople = [EPersonMock, EPersonMock2]; mockEPeople = [EPersonMock, EPersonMock2];
ePersonDataServiceStub = { ePersonDataServiceStub = {
findAllByHref(href: string): Observable<RemoteData<PaginatedList<EPerson>>> { findListByHref(href: string): Observable<RemoteData<PaginatedList<EPerson>>> {
switch (href) { switch (href) {
case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/epersons': case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/epersons':
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({ return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({
@@ -97,7 +97,7 @@ describe('GroupRegistryComponent', () => {
}; };
groupsDataServiceStub = { groupsDataServiceStub = {
allGroups: mockGroups, allGroups: mockGroups,
findAllByHref(href: string): Observable<RemoteData<PaginatedList<Group>>> { findListByHref(href: string): Observable<RemoteData<PaginatedList<Group>>> {
switch (href) { switch (href) {
case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/groups': case 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/groups':
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({ return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo({

View File

@@ -213,7 +213,7 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
* @param group * @param group
*/ */
getMembers(group: Group): Observable<RemoteData<PaginatedList<EPerson>>> { getMembers(group: Group): Observable<RemoteData<PaginatedList<EPerson>>> {
return this.ePersonDataService.findAllByHref(group._links.epersons.href).pipe(getFirstSucceededRemoteData()); return this.ePersonDataService.findListByHref(group._links.epersons.href).pipe(getFirstSucceededRemoteData());
} }
/** /**
@@ -221,7 +221,7 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
* @param group * @param group
*/ */
getSubgroups(group: Group): Observable<RemoteData<PaginatedList<Group>>> { getSubgroups(group: Group): Observable<RemoteData<PaginatedList<Group>>> {
return this.groupService.findAllByHref(group._links.subgroups.href).pipe(getFirstSucceededRemoteData()); return this.groupService.findListByHref(group._links.subgroups.href).pipe(getFirstSucceededRemoteData());
} }
/** /**

View File

@@ -1,4 +1,4 @@
<nav @slideHorizontal class="navbar navbar-dark p-0" <nav class="navbar navbar-dark p-0"
[ngClass]="{'active': sidebarOpen, 'inactive': sidebarClosed}" [ngClass]="{'active': sidebarOpen, 'inactive': sidebarClosed}"
[@slideSidebar]="{ [@slideSidebar]="{
value: (!(sidebarExpanded | async) ? 'collapsed' : 'expanded'), value: (!(sidebarExpanded | async) ? 'collapsed' : 'expanded'),

View File

@@ -2,7 +2,7 @@ import { Component, HostListener, Injector, OnInit } from '@angular/core';
import { BehaviorSubject, combineLatest, Observable } from 'rxjs'; import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
import { debounceTime, distinctUntilChanged, first, map, withLatestFrom } from 'rxjs/operators'; import { debounceTime, distinctUntilChanged, first, map, withLatestFrom } from 'rxjs/operators';
import { AuthService } from '../../core/auth/auth.service'; import { AuthService } from '../../core/auth/auth.service';
import { slideHorizontal, slideSidebar } from '../../shared/animations/slide'; import { slideSidebar } from '../../shared/animations/slide';
import { MenuComponent } from '../../shared/menu/menu.component'; import { MenuComponent } from '../../shared/menu/menu.component';
import { MenuService } from '../../shared/menu/menu.service'; import { MenuService } from '../../shared/menu/menu.service';
import { CSSVariableService } from '../../shared/sass-helper/sass-helper.service'; import { CSSVariableService } from '../../shared/sass-helper/sass-helper.service';
@@ -18,7 +18,7 @@ import { ThemeService } from '../../shared/theme-support/theme.service';
selector: 'ds-admin-sidebar', selector: 'ds-admin-sidebar',
templateUrl: './admin-sidebar.component.html', templateUrl: './admin-sidebar.component.html',
styleUrls: ['./admin-sidebar.component.scss'], styleUrls: ['./admin-sidebar.component.scss'],
animations: [slideHorizontal, slideSidebar] animations: [slideSidebar]
}) })
export class AdminSidebarComponent extends MenuComponent implements OnInit { export class AdminSidebarComponent extends MenuComponent implements OnInit {
/** /**

View File

@@ -32,7 +32,6 @@ import { storeModuleConfig } from './app.reducer';
import { LocaleService } from './core/locale/locale.service'; import { LocaleService } from './core/locale/locale.service';
import { authReducer } from './core/auth/auth.reducer'; import { authReducer } from './core/auth/auth.reducer';
import { provideMockStore } from '@ngrx/store/testing'; import { provideMockStore } from '@ngrx/store/testing';
import { GoogleAnalyticsService } from './statistics/google-analytics.service';
import { ThemeService } from './shared/theme-support/theme.service'; import { ThemeService } from './shared/theme-support/theme.service';
import { getMockThemeService } from './shared/mocks/theme-service.mock'; import { getMockThemeService } from './shared/mocks/theme-service.mock';
import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service'; import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service';
@@ -46,15 +45,15 @@ const initialState = {
core: { auth: { loading: false } } core: { auth: { loading: false } }
}; };
describe('App component', () => { export function getMockLocaleService(): LocaleService {
let breadcrumbsServiceSpy;
function getMockLocaleService(): LocaleService {
return jasmine.createSpyObj('LocaleService', { return jasmine.createSpyObj('LocaleService', {
setCurrentLanguageCode: jasmine.createSpy('setCurrentLanguageCode') setCurrentLanguageCode: jasmine.createSpy('setCurrentLanguageCode')
}); });
} }
describe('App component', () => {
let breadcrumbsServiceSpy;
const getDefaultTestBedConf = () => { const getDefaultTestBedConf = () => {
breadcrumbsServiceSpy = jasmine.createSpyObj(['listenForRouteChanges']); breadcrumbsServiceSpy = jasmine.createSpyObj(['listenForRouteChanges']);
@@ -130,66 +129,4 @@ describe('App component', () => {
}); });
}); });
describe('the constructor', () => {
it('should call breadcrumbsService.listenForRouteChanges', () => {
expect(breadcrumbsServiceSpy.listenForRouteChanges).toHaveBeenCalledTimes(1);
});
});
describe('when GoogleAnalyticsService is provided', () => {
let googleAnalyticsSpy;
beforeEach(() => {
// NOTE: Cannot override providers once components have been compiled, so TestBed needs to be reset
TestBed.resetTestingModule();
TestBed.configureTestingModule(getDefaultTestBedConf());
googleAnalyticsSpy = jasmine.createSpyObj('googleAnalyticsService', [
'addTrackingIdToPage',
]);
TestBed.overrideProvider(GoogleAnalyticsService, {useValue: googleAnalyticsSpy});
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
it('should create component', () => {
expect(comp).toBeTruthy();
});
describe('the constructor', () => {
it('should call googleAnalyticsService.addTrackingIdToPage()', () => {
expect(googleAnalyticsSpy.addTrackingIdToPage).toHaveBeenCalledTimes(1);
});
});
});
describe('when ThemeService returns a custom theme', () => {
let document;
let headSpy;
beforeEach(() => {
// NOTE: Cannot override providers once components have been compiled, so TestBed needs to be reset
TestBed.resetTestingModule();
TestBed.configureTestingModule(getDefaultTestBedConf());
TestBed.overrideProvider(ThemeService, {useValue: getMockThemeService('custom')});
document = TestBed.inject(DOCUMENT);
headSpy = jasmine.createSpyObj('head', ['appendChild', 'getElementsByClassName']);
headSpy.getElementsByClassName.and.returnValue([]);
spyOn(document, 'getElementsByTagName').and.returnValue([headSpy]);
fixture = TestBed.createComponent(AppComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
it('should append a link element with the correct attributes to the head element', () => {
const link = document.createElement('link');
link.setAttribute('rel', 'stylesheet');
link.setAttribute('type', 'text/css');
link.setAttribute('class', 'theme-css');
link.setAttribute('href', 'custom-theme.css');
expect(headSpy.appendChild).toHaveBeenCalledWith(link);
});
});
}); });

View File

@@ -1,4 +1,4 @@
import { distinctUntilChanged, filter, switchMap, take, withLatestFrom } from 'rxjs/operators'; import { distinctUntilChanged, take, withLatestFrom } from 'rxjs/operators';
import { DOCUMENT, isPlatformBrowser } from '@angular/common'; import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { import {
AfterViewInit, AfterViewInit,
@@ -7,48 +7,30 @@ import {
HostListener, HostListener,
Inject, Inject,
OnInit, OnInit,
Optional,
PLATFORM_ID, PLATFORM_ID,
} from '@angular/core'; } from '@angular/core';
import { import {
ActivatedRouteSnapshot,
ActivationEnd,
NavigationCancel, NavigationCancel,
NavigationEnd, NavigationEnd,
NavigationStart, ResolveEnd, NavigationStart,
Router, Router,
} from '@angular/router'; } from '@angular/router';
import { isEqual } from 'lodash'; import { BehaviorSubject, Observable } from 'rxjs';
import { BehaviorSubject, Observable, of } from 'rxjs';
import { select, Store } from '@ngrx/store'; import { select, Store } from '@ngrx/store';
import { NgbModal, NgbModalConfig } from '@ng-bootstrap/ng-bootstrap'; import { NgbModal, NgbModalConfig } from '@ng-bootstrap/ng-bootstrap';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { Angulartics2GoogleAnalytics } from 'angulartics2';
import { MetadataService } from './core/metadata/metadata.service';
import { HostWindowResizeAction } from './shared/host-window.actions'; import { HostWindowResizeAction } from './shared/host-window.actions';
import { HostWindowState } from './shared/search/host-window.reducer'; import { HostWindowState } from './shared/search/host-window.reducer';
import { NativeWindowRef, NativeWindowService } from './core/services/window.service'; import { NativeWindowRef, NativeWindowService } from './core/services/window.service';
import { isAuthenticationBlocking } from './core/auth/selectors'; import { isAuthenticationBlocking } from './core/auth/selectors';
import { AuthService } from './core/auth/auth.service'; import { AuthService } from './core/auth/auth.service';
import { CSSVariableService } from './shared/sass-helper/sass-helper.service'; import { CSSVariableService } from './shared/sass-helper/sass-helper.service';
import { MenuService } from './shared/menu/menu.service';
import { HostWindowService } from './shared/host-window.service';
import { HeadTagConfig, ThemeConfig } from '../config/theme.model';
import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider';
import { environment } from '../environments/environment'; import { environment } from '../environments/environment';
import { models } from './core/core.module'; import { models } from './core/core.module';
import { LocaleService } from './core/locale/locale.service';
import { hasNoValue, hasValue, isNotEmpty } from './shared/empty.util';
import { KlaroService } from './shared/cookies/klaro.service';
import { GoogleAnalyticsService } from './statistics/google-analytics.service';
import { ThemeService } from './shared/theme-support/theme.service'; import { ThemeService } from './shared/theme-support/theme.service';
import { BASE_THEME_NAME } from './shared/theme-support/theme.constants';
import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service';
import { IdleModalComponent } from './shared/idle-modal/idle-modal.component'; import { IdleModalComponent } from './shared/idle-modal/idle-modal.component';
import { getDefaultThemeConfig } from '../config/config.util'; import { distinctNext } from './core/shared/distinct-next';
import { AppConfig, APP_CONFIG } from 'src/config/app-config.interface';
import { ModalBeforeDismiss } from './shared/interfaces/modal-before-dismiss.interface'; import { ModalBeforeDismiss } from './shared/interfaces/modal-before-dismiss.interface';
@Component({ @Component({
@@ -58,11 +40,6 @@ import { ModalBeforeDismiss } from './shared/interfaces/modal-before-dismiss.int
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class AppComponent implements OnInit, AfterViewInit { export class AppComponent implements OnInit, AfterViewInit {
sidebarVisible: Observable<boolean>;
slideSidebarOver: Observable<boolean>;
collapsedSidebarWidth: Observable<string>;
totalSidebarWidth: Observable<string>;
theme: Observable<ThemeConfig> = of({} as any);
notificationOptions; notificationOptions;
models; models;
@@ -79,9 +56,7 @@ export class AppComponent implements OnInit, AfterViewInit {
/** /**
* Whether or not the theme is in the process of being swapped * Whether or not the theme is in the process of being swapped
*/ */
isThemeLoading$: BehaviorSubject<boolean> = new BehaviorSubject(false); isThemeLoading$: Observable<boolean>;
isThemeCSSLoading$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
/** /**
* Whether or not the idle modal is is currently open * Whether or not the idle modal is is currently open
@@ -92,78 +67,26 @@ export class AppComponent implements OnInit, AfterViewInit {
@Inject(NativeWindowService) private _window: NativeWindowRef, @Inject(NativeWindowService) private _window: NativeWindowRef,
@Inject(DOCUMENT) private document: any, @Inject(DOCUMENT) private document: any,
@Inject(PLATFORM_ID) private platformId: any, @Inject(PLATFORM_ID) private platformId: any,
@Inject(APP_CONFIG) private appConfig: AppConfig,
private themeService: ThemeService, private themeService: ThemeService,
private translate: TranslateService, private translate: TranslateService,
private store: Store<HostWindowState>, private store: Store<HostWindowState>,
private metadata: MetadataService,
private angulartics2GoogleAnalytics: Angulartics2GoogleAnalytics,
private angulartics2DSpace: Angulartics2DSpace,
private authService: AuthService, private authService: AuthService,
private router: Router, private router: Router,
private cssService: CSSVariableService, private cssService: CSSVariableService,
private menuService: MenuService,
private windowService: HostWindowService,
private localeService: LocaleService,
private breadcrumbsService: BreadcrumbsService,
private modalService: NgbModal, private modalService: NgbModal,
private modalConfig: NgbModalConfig, private modalConfig: NgbModalConfig,
@Optional() private cookiesService: KlaroService,
@Optional() private googleAnalyticsService: GoogleAnalyticsService,
) { ) {
if (!isEqual(environment, this.appConfig)) {
throw new Error('environment does not match app config!');
}
this.notificationOptions = environment.notifications; this.notificationOptions = environment.notifications;
/* Use models object so all decorators are actually called */ /* Use models object so all decorators are actually called */
this.models = models; this.models = models;
this.themeService.getThemeName$().subscribe((themeName: string) => {
if (isPlatformBrowser(this.platformId)) { if (isPlatformBrowser(this.platformId)) {
// the theme css will never download server side, so this should only happen on the browser
this.distinctNext(this.isThemeCSSLoading$, true);
}
if (hasValue(themeName)) {
this.loadGlobalThemeConfig(themeName);
} else {
const defaultThemeConfig = getDefaultThemeConfig();
if (hasValue(defaultThemeConfig)) {
this.loadGlobalThemeConfig(defaultThemeConfig.name);
} else {
this.loadGlobalThemeConfig(BASE_THEME_NAME);
}
}
});
if (isPlatformBrowser(this.platformId)) {
this.authService.trackTokenExpiration();
this.trackIdleModal(); this.trackIdleModal();
} }
// Load all the languages that are defined as active from the config file this.isThemeLoading$ = this.themeService.isThemeLoading$;
translate.addLangs(environment.languages.filter((LangConfig) => LangConfig.active === true).map((a) => a.code));
// Load the default language from the config file
// translate.setDefaultLang(environment.defaultLanguage);
// set the current language code
this.localeService.setCurrentLanguageCode();
// analytics
if (hasValue(googleAnalyticsService)) {
googleAnalyticsService.addTrackingIdToPage();
}
angulartics2DSpace.startTracking();
metadata.listenForRouteChange();
breadcrumbsService.listenForRouteChanges();
if (environment.debug) {
console.info(environment);
}
this.storeCSSVariables(); this.storeCSSVariables();
} }
@@ -178,18 +101,11 @@ export class AppComponent implements OnInit, AfterViewInit {
return true; return true;
}; };
this.isAuthBlocking$ = this.store.pipe(select(isAuthenticationBlocking)).pipe( this.isAuthBlocking$ = this.store.pipe(
select(isAuthenticationBlocking),
distinctUntilChanged() distinctUntilChanged()
); );
this.isAuthBlocking$
.pipe(
filter((isBlocking: boolean) => isBlocking === false),
take(1)
).subscribe(() => this.initializeKlaro());
const env: string = environment.production ? 'Production' : 'Development';
const color: string = environment.production ? 'red' : 'green';
console.info(`Environment: %c${env}`, `color: ${color}; font-weight: bold;`);
this.dispatchWindowSize(this._window.nativeWindow.innerWidth, this._window.nativeWindow.innerHeight); this.dispatchWindowSize(this._window.nativeWindow.innerWidth, this._window.nativeWindow.innerHeight);
} }
@@ -209,54 +125,15 @@ export class AppComponent implements OnInit, AfterViewInit {
} }
ngAfterViewInit() { ngAfterViewInit() {
let updatingTheme = false;
let snapshot: ActivatedRouteSnapshot;
this.router.events.subscribe((event) => { this.router.events.subscribe((event) => {
if (event instanceof NavigationStart) { if (event instanceof NavigationStart) {
updatingTheme = false; distinctNext(this.isRouteLoading$, true);
this.distinctNext(this.isRouteLoading$, true); } else if (
} else if (event instanceof ResolveEnd) { event instanceof NavigationEnd ||
// this is the earliest point where we have all the information we need event instanceof NavigationCancel
// to update the theme, but this event is not emitted on first load ) {
this.updateTheme(event.urlAfterRedirects, event.state.root); distinctNext(this.isRouteLoading$, false);
updatingTheme = true;
} else if (!updatingTheme && event instanceof ActivationEnd) {
// if there was no ResolveEnd, keep track of the snapshot...
snapshot = event.snapshot;
} else if (event instanceof NavigationEnd) {
if (!updatingTheme) {
// ...and use it to update the theme on NavigationEnd instead
this.updateTheme(event.urlAfterRedirects, snapshot);
updatingTheme = true;
} }
this.distinctNext(this.isRouteLoading$, false);
} else if (event instanceof NavigationCancel) {
if (!updatingTheme) {
this.distinctNext(this.isThemeLoading$, false);
}
this.distinctNext(this.isRouteLoading$, false);
}
});
}
/**
* Update the theme according to the current route, if applicable.
* @param urlAfterRedirects the current URL after redirects
* @param snapshot the current route snapshot
* @private
*/
private updateTheme(urlAfterRedirects: string, snapshot: ActivatedRouteSnapshot): void {
this.themeService.updateThemeOnRouteChange$(urlAfterRedirects, snapshot).pipe(
switchMap((changed) => {
if (changed) {
return this.isThemeCSSLoading$;
} else {
return [false];
}
})
).subscribe((changed) => {
this.distinctNext(this.isThemeLoading$, changed);
}); });
} }
@@ -271,125 +148,6 @@ export class AppComponent implements OnInit, AfterViewInit {
); );
} }
private initializeKlaro() {
if (hasValue(this.cookiesService)) {
this.cookiesService.initialize();
}
}
private loadGlobalThemeConfig(themeName: string): void {
this.setThemeCss(themeName);
this.setHeadTags(themeName);
}
/**
* Update the theme css file in <head>
*
* @param themeName The name of the new theme
* @private
*/
private setThemeCss(themeName: string): void {
const head = this.document.getElementsByTagName('head')[0];
if (hasNoValue(head)) {
return;
}
// Array.from to ensure we end up with an array, not an HTMLCollection, which would be
// automatically updated if we add nodes later
const currentThemeLinks = Array.from(head.getElementsByClassName('theme-css'));
const link = this.document.createElement('link');
link.setAttribute('rel', 'stylesheet');
link.setAttribute('type', 'text/css');
link.setAttribute('class', 'theme-css');
link.setAttribute('href', `${encodeURIComponent(themeName)}-theme.css`);
// wait for the new css to download before removing the old one to prevent a
// flash of unstyled content
link.onload = () => {
if (isNotEmpty(currentThemeLinks)) {
currentThemeLinks.forEach((currentThemeLink: any) => {
if (hasValue(currentThemeLink)) {
currentThemeLink.remove();
}
});
}
// the fact that this callback is used, proves we're on the browser.
this.distinctNext(this.isThemeCSSLoading$, false);
};
head.appendChild(link);
}
private setHeadTags(themeName: string): void {
const head = this.document.getElementsByTagName('head')[0];
if (hasNoValue(head)) {
return;
}
// clear head tags
const currentHeadTags = Array.from(head.getElementsByClassName('theme-head-tag'));
if (hasValue(currentHeadTags)) {
currentHeadTags.forEach((currentHeadTag: any) => currentHeadTag.remove());
}
// create new head tags (not yet added to DOM)
const headTagFragment = this.document.createDocumentFragment();
this.createHeadTags(themeName)
.forEach(newHeadTag => headTagFragment.appendChild(newHeadTag));
// add new head tags to DOM
head.appendChild(headTagFragment);
}
private createHeadTags(themeName: string): HTMLElement[] {
const themeConfig = this.themeService.getThemeConfigFor(themeName);
const headTagConfigs = themeConfig?.headTags;
if (hasNoValue(headTagConfigs)) {
const parentThemeName = themeConfig?.extends;
if (hasValue(parentThemeName)) {
// inherit the head tags of the parent theme
return this.createHeadTags(parentThemeName);
}
const defaultThemeConfig = getDefaultThemeConfig();
const defaultThemeName = defaultThemeConfig.name;
if (
hasNoValue(defaultThemeName) ||
themeName === defaultThemeName ||
themeName === BASE_THEME_NAME
) {
// last resort, use fallback favicon.ico
return [
this.createHeadTag({
'tagName': 'link',
'attributes': {
'rel': 'icon',
'href': 'assets/images/favicon.ico',
'sizes': 'any',
}
})
];
}
// inherit the head tags of the default theme
return this.createHeadTags(defaultThemeConfig.name);
}
return headTagConfigs.map(this.createHeadTag.bind(this));
}
private createHeadTag(headTagConfig: HeadTagConfig): HTMLElement {
const tag = this.document.createElement(headTagConfig.tagName);
if (hasValue(headTagConfig.attributes)) {
Object.entries(headTagConfig.attributes)
.forEach(([key, value]) => tag.setAttribute(key, value));
}
// 'class' attribute should always be 'theme-head-tag' for removal
tag.setAttribute('class', 'theme-head-tag');
return tag;
}
private trackIdleModal() { private trackIdleModal() {
const isIdle$ = this.authService.isUserIdle(); const isIdle$ = this.authService.isUserIdle();
const isAuthenticated$ = this.authService.isAuthenticated(); const isAuthenticated$ = this.authService.isAuthenticated();
@@ -409,16 +167,4 @@ export class AppComponent implements OnInit, AfterViewInit {
}); });
} }
/**
* Use nextValue to update a given BehaviorSubject, only if it differs from its current value
*
* @param bs a BehaviorSubject
* @param nextValue the next value for that BehaviorSubject
* @protected
*/
protected distinctNext<T>(bs: BehaviorSubject<T>, nextValue: T): void {
if (bs.getValue() !== nextValue) {
bs.next(nextValue);
}
}
} }

25
src/app/app.module.ts Executable file → Normal file
View File

@@ -1,18 +1,14 @@
import { APP_BASE_HREF, CommonModule, DOCUMENT } from '@angular/common'; import { APP_BASE_HREF, CommonModule, DOCUMENT } from '@angular/common';
import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http'; import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http';
import { APP_INITIALIZER, NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { AbstractControl } from '@angular/forms'; import { AbstractControl } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser'; import { BrowserModule } from '@angular/platform-browser';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { EffectsModule } from '@ngrx/effects'; import { EffectsModule } from '@ngrx/effects';
import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store'; import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store';
import { MetaReducer, Store, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store'; import { MetaReducer, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store';
import { import { DYNAMIC_ERROR_MESSAGES_MATCHER, DYNAMIC_MATCHER_PROVIDERS, DynamicErrorMessagesMatcher } from '@ng-dynamic-forms/core';
DYNAMIC_ERROR_MESSAGES_MATCHER,
DYNAMIC_MATCHER_PROVIDERS,
DynamicErrorMessagesMatcher
} from '@ng-dynamic-forms/core';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to'; import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to';
import { AppRoutingModule } from './app-routing.module'; import { AppRoutingModule } from './app-routing.module';
@@ -20,7 +16,6 @@ import { AppComponent } from './app.component';
import { appEffects } from './app.effects'; import { appEffects } from './app.effects';
import { appMetaReducers, debugMetaReducers } from './app.metareducers'; import { appMetaReducers, debugMetaReducers } from './app.metareducers';
import { appReducers, AppState, storeModuleConfig } from './app.reducer'; import { appReducers, AppState, storeModuleConfig } from './app.reducer';
import { CheckAuthenticationTokenAction } from './core/auth/auth.actions';
import { CoreModule } from './core/core.module'; import { CoreModule } from './core/core.module';
import { ClientCookieService } from './core/services/client-cookie.service'; import { ClientCookieService } from './core/services/client-cookie.service';
import { NavbarModule } from './navbar/navbar.module'; import { NavbarModule } from './navbar/navbar.module';
@@ -32,7 +27,6 @@ import { LocaleInterceptor } from './core/locale/locale.interceptor';
import { XsrfInterceptor } from './core/xsrf/xsrf.interceptor'; import { XsrfInterceptor } from './core/xsrf/xsrf.interceptor';
import { LogInterceptor } from './core/log/log.interceptor'; import { LogInterceptor } from './core/log/log.interceptor';
import { EagerThemesModule } from '../themes/eager-themes.module'; import { EagerThemesModule } from '../themes/eager-themes.module';
import { APP_CONFIG, AppConfig } from '../config/app-config.interface'; import { APP_CONFIG, AppConfig } from '../config/app-config.interface';
import { NgxMaskModule } from 'ngx-mask'; import { NgxMaskModule } from 'ngx-mask';
import { StoreDevModules } from '../config/store/devtools'; import { StoreDevModules } from '../config/store/devtools';
@@ -80,10 +74,6 @@ const IMPORTS = [
]; ];
const PROVIDERS = [ const PROVIDERS = [
{
provide: APP_CONFIG,
useFactory: getConfig
},
{ {
provide: APP_BASE_HREF, provide: APP_BASE_HREF,
useFactory: getBaseHref, useFactory: getBaseHref,
@@ -99,15 +89,6 @@ const PROVIDERS = [
useClass: DSpaceRouterStateSerializer useClass: DSpaceRouterStateSerializer
}, },
ClientCookieService, ClientCookieService,
// Check the authentication token when the app initializes
{
provide: APP_INITIALIZER,
useFactory: (store: Store<AppState>,) => {
return () => store.dispatch(new CheckAuthenticationTokenAction());
},
deps: [Store],
multi: true
},
// register AuthInterceptor as HttpInterceptor // register AuthInterceptor as HttpInterceptor
{ {
provide: HTTP_INTERCEPTORS, provide: HTTP_INTERCEPTORS,

View File

@@ -18,6 +18,8 @@ import { BrowseByDataType, rendersBrowseBy } from '../browse-by-switcher/browse-
import { PaginationService } from '../../core/pagination/pagination.service'; import { PaginationService } from '../../core/pagination/pagination.service';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
export const BBM_PAGINATION_ID = 'bbm';
@Component({ @Component({
selector: 'ds-browse-by-metadata-page', selector: 'ds-browse-by-metadata-page',
styleUrls: ['./browse-by-metadata-page.component.scss'], styleUrls: ['./browse-by-metadata-page.component.scss'],
@@ -50,7 +52,7 @@ export class BrowseByMetadataPageComponent implements OnInit {
* The pagination config used to display the values * The pagination config used to display the values
*/ */
paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { paginationConfig: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
id: 'bbm', id: BBM_PAGINATION_ID,
currentPage: 1, currentPage: 1,
pageSize: 20 pageSize: 20
}); });

View File

@@ -16,7 +16,7 @@ import { CommunityDataService } from '../../core/data/community-data.service';
import { AuthService } from '../../core/auth/auth.service'; import { AuthService } from '../../core/auth/auth.service';
import { RequestService } from '../../core/data/request.service'; import { RequestService } from '../../core/data/request.service';
import { ObjectCacheService } from '../../core/cache/object-cache.service'; import { ObjectCacheService } from '../../core/cache/object-cache.service';
import { EntityTypeService } from '../../core/data/entity-type.service'; import { EntityTypeDataService } from '../../core/data/entity-type-data.service';
import { ItemType } from '../../core/shared/item-relationships/item-type.model'; import { ItemType } from '../../core/shared/item-relationships/item-type.model';
import { MetadataValue } from '../../core/shared/metadata.models'; import { MetadataValue } from '../../core/shared/metadata.models';
import { getFirstSucceededRemoteListPayload } from '../../core/shared/operators'; import { getFirstSucceededRemoteListPayload } from '../../core/shared/operators';
@@ -61,7 +61,7 @@ export class CollectionFormComponent extends ComColFormComponent<Collection> imp
protected dsoService: CommunityDataService, protected dsoService: CommunityDataService,
protected requestService: RequestService, protected requestService: RequestService,
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
protected entityTypeService: EntityTypeService) { protected entityTypeService: EntityTypeDataService) {
super(formService, translate, notificationsService, authService, requestService, objectCache); super(formService, translate, notificationsService, authService, requestService, objectCache);
} }

View File

@@ -94,7 +94,7 @@ describe('CollectionItemMapperComponent', () => {
const emptyList = createSuccessfulRemoteDataObject(createPaginatedList([])); const emptyList = createSuccessfulRemoteDataObject(createPaginatedList([]));
const itemDataServiceStub = { const itemDataServiceStub = {
mapToCollection: () => createSuccessfulRemoteDataObject$({}), mapToCollection: () => createSuccessfulRemoteDataObject$({}),
findAllByHref: () => observableOf(emptyList) findListByHref: () => observableOf(emptyList),
}; };
const activatedRouteStub = { const activatedRouteStub = {
parent: { parent: {
@@ -152,7 +152,7 @@ describe('CollectionItemMapperComponent', () => {
}); });
const groupDataService = jasmine.createSpyObj('groupsDataService', { const groupDataService = jasmine.createSpyObj('groupsDataService', {
findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])),
getGroupRegistryRouterLink: '', getGroupRegistryRouterLink: '',
getUUIDFromString: '', getUUIDFromString: '',
}); });

View File

@@ -143,7 +143,7 @@ export class CollectionItemMapperComponent implements OnInit {
if (shouldUpdate === true) { if (shouldUpdate === true) {
this.shouldUpdate$.next(false); this.shouldUpdate$.next(false);
} }
return this.itemDataService.findAllByHref(collectionRD.payload._links.mappedItems.href, Object.assign(options, { return this.itemDataService.findListByHref(collectionRD.payload._links.mappedItems.href, Object.assign(options, {
sort: this.defaultSortOptions sort: this.defaultSortOptions
}),!shouldUpdate, false, followLink('owningCollection')).pipe( }),!shouldUpdate, false, followLink('owningCollection')).pipe(
getAllSucceededRemoteData() getAllSucceededRemoteData()

View File

@@ -13,7 +13,7 @@ import { Item } from '../../../core/shared/item.model';
import { ItemTemplateDataService } from '../../../core/data/item-template-data.service'; import { ItemTemplateDataService } from '../../../core/data/item-template-data.service';
import { Collection } from '../../../core/shared/collection.model'; import { Collection } from '../../../core/shared/collection.model';
import { RequestService } from '../../../core/data/request.service'; import { RequestService } from '../../../core/data/request.service';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { getCollectionItemTemplateRoute } from '../../collection-page-routing-paths'; import { getCollectionItemTemplateRoute } from '../../collection-page-routing-paths';
describe('CollectionMetadataComponent', () => { describe('CollectionMetadataComponent', () => {
@@ -39,8 +39,8 @@ describe('CollectionMetadataComponent', () => {
const itemTemplateServiceStub = jasmine.createSpyObj('itemTemplateService', { const itemTemplateServiceStub = jasmine.createSpyObj('itemTemplateService', {
findByCollectionID: createSuccessfulRemoteDataObject$(template), findByCollectionID: createSuccessfulRemoteDataObject$(template),
create: createSuccessfulRemoteDataObject$(template), createByCollectionID: createSuccessfulRemoteDataObject$(template),
deleteByCollectionID: observableOf(true), delete: observableOf(true),
getCollectionEndpoint: observableOf(collectionTemplateHref), getCollectionEndpoint: observableOf(collectionTemplateHref),
}); });
@@ -91,12 +91,12 @@ describe('CollectionMetadataComponent', () => {
describe('deleteItemTemplate', () => { describe('deleteItemTemplate', () => {
beforeEach(() => { beforeEach(() => {
(itemTemplateService.deleteByCollectionID as jasmine.Spy).and.returnValue(observableOf(true)); (itemTemplateService.delete as jasmine.Spy).and.returnValue(createSuccessfulRemoteDataObject$({}));
comp.deleteItemTemplate(); comp.deleteItemTemplate();
}); });
it('should call ItemTemplateService.deleteByCollectionID', () => { it('should call ItemTemplateService.delete', () => {
expect(itemTemplateService.deleteByCollectionID).toHaveBeenCalledWith(template, 'collection-id'); expect(itemTemplateService.delete).toHaveBeenCalledWith(template.uuid);
}); });
describe('when delete returns a success', () => { describe('when delete returns a success', () => {
@@ -107,7 +107,7 @@ describe('CollectionMetadataComponent', () => {
describe('when delete returns a failure', () => { describe('when delete returns a failure', () => {
beforeEach(() => { beforeEach(() => {
(itemTemplateService.deleteByCollectionID as jasmine.Spy).and.returnValue(observableOf(false)); (itemTemplateService.delete as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$());
comp.deleteItemTemplate(); comp.deleteItemTemplate();
}); });

View File

@@ -7,12 +7,14 @@ import { ItemTemplateDataService } from '../../../core/data/item-template-data.s
import { combineLatest as combineLatestObservable, Observable } from 'rxjs'; import { combineLatest as combineLatestObservable, Observable } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data'; import { RemoteData } from '../../../core/data/remote-data';
import { Item } from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators';
import { switchMap } from 'rxjs/operators'; import { map, switchMap } from 'rxjs/operators';
import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { RequestService } from '../../../core/data/request.service'; import { RequestService } from '../../../core/data/request.service';
import { getCollectionItemTemplateRoute } from '../../collection-page-routing-paths'; import { getCollectionItemTemplateRoute } from '../../collection-page-routing-paths';
import { NoContent } from '../../../core/shared/NoContent.model';
import { hasValue } from '../../../shared/empty.util';
/** /**
* Component for editing a collection's metadata * Component for editing a collection's metadata
@@ -65,7 +67,7 @@ export class CollectionMetadataComponent extends ComcolMetadataComponent<Collect
getFirstSucceededRemoteDataPayload(), getFirstSucceededRemoteDataPayload(),
); );
const template$ = collection$.pipe( const template$ = collection$.pipe(
switchMap((collection: Collection) => this.itemTemplateService.create(new Item(), collection.uuid).pipe( switchMap((collection: Collection) => this.itemTemplateService.createByCollectionID(new Item(), collection.uuid).pipe(
getFirstSucceededRemoteDataPayload(), getFirstSucceededRemoteDataPayload(),
)), )),
); );
@@ -83,18 +85,15 @@ export class CollectionMetadataComponent extends ComcolMetadataComponent<Collect
* Delete the item template from the collection * Delete the item template from the collection
*/ */
deleteItemTemplate() { deleteItemTemplate() {
const collection$ = this.dsoRD$.pipe( this.dsoRD$.pipe(
getFirstSucceededRemoteDataPayload(), getFirstSucceededRemoteDataPayload(),
); switchMap((collection: Collection) => this.itemTemplateService.findByCollectionID(collection.uuid)),
const template$ = collection$.pipe(
switchMap((collection: Collection) => this.itemTemplateService.findByCollectionID(collection.uuid).pipe(
getFirstSucceededRemoteDataPayload(), getFirstSucceededRemoteDataPayload(),
)), switchMap((template) => {
); return this.itemTemplateService.delete(template.uuid);
combineLatestObservable(collection$, template$).pipe( }),
switchMap(([collection, template]) => { getFirstCompletedRemoteData(),
return this.itemTemplateService.deleteByCollectionID(template, collection.uuid); map((response: RemoteData<NoContent>) => hasValue(response) && response.hasSucceeded),
})
).subscribe((success: boolean) => { ).subscribe((success: boolean) => {
if (success) { if (success) {
this.notificationsService.success(null, this.translate.get('collection.edit.template.notifications.delete.success')); this.notificationsService.success(null, this.translate.get('collection.edit.template.notifications.delete.success'));

View File

@@ -15,6 +15,8 @@ import { Collection } from '../core/shared/collection.model';
import { PageInfo } from '../core/shared/page-info.model'; import { PageInfo } from '../core/shared/page-info.model';
import { FlatNode } from './flat-node.model'; import { FlatNode } from './flat-node.model';
import { FindListOptions } from '../core/data/find-list-options.model'; import { FindListOptions } from '../core/data/find-list-options.model';
import { APP_CONFIG } from 'src/config/app-config.interface';
import { environment } from 'src/environments/environment.test';
describe('CommunityListService', () => { describe('CommunityListService', () => {
let store: StoreMock<AppState>; let store: StoreMock<AppState>;
@@ -191,13 +193,14 @@ describe('CommunityListService', () => {
}; };
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [CommunityListService, providers: [CommunityListService,
{ provide: APP_CONFIG, useValue: environment },
{ provide: CollectionDataService, useValue: collectionDataServiceStub }, { provide: CollectionDataService, useValue: collectionDataServiceStub },
{ provide: CommunityDataService, useValue: communityDataServiceStub }, { provide: CommunityDataService, useValue: communityDataServiceStub },
{ provide: Store, useValue: StoreMock }, { provide: Store, useValue: StoreMock },
], ],
}); });
store = TestBed.inject(Store as any); store = TestBed.inject(Store as any);
service = new CommunityListService(communityDataServiceStub, collectionDataServiceStub, store); service = new CommunityListService(environment, communityDataServiceStub, collectionDataServiceStub, store);
}); });
it('should create', inject([CommunityListService], (serviceIn: CommunityListService) => { it('should create', inject([CommunityListService], (serviceIn: CommunityListService) => {

View File

@@ -1,5 +1,5 @@
/* eslint-disable max-classes-per-file */ /* eslint-disable max-classes-per-file */
import { Injectable } from '@angular/core'; import { Inject, Injectable } from '@angular/core';
import { createSelector, Store } from '@ngrx/store'; import { createSelector, Store } from '@ngrx/store';
import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs';
@@ -23,6 +23,7 @@ import { followLink } from '../shared/utils/follow-link-config.model';
import { FlatNode } from './flat-node.model'; import { FlatNode } from './flat-node.model';
import { ShowMoreFlatNode } from './show-more-flat-node.model'; import { ShowMoreFlatNode } from './show-more-flat-node.model';
import { FindListOptions } from '../core/data/find-list-options.model'; import { FindListOptions } from '../core/data/find-list-options.model';
import { AppConfig, APP_CONFIG } from 'src/config/app-config.interface';
// Helper method to combine an flatten an array of observables of flatNode arrays // Helper method to combine an flatten an array of observables of flatNode arrays
export const combineAndFlatten = (obsList: Observable<FlatNode[]>[]): Observable<FlatNode[]> => export const combineAndFlatten = (obsList: Observable<FlatNode[]>[]): Observable<FlatNode[]> =>
@@ -80,8 +81,6 @@ const communityListStateSelector = (state: AppState) => state.communityList;
const expandedNodesSelector = createSelector(communityListStateSelector, (communityList: CommunityListState) => communityList.expandedNodes); const expandedNodesSelector = createSelector(communityListStateSelector, (communityList: CommunityListState) => communityList.expandedNodes);
const loadingNodeSelector = createSelector(communityListStateSelector, (communityList: CommunityListState) => communityList.loadingNode); const loadingNodeSelector = createSelector(communityListStateSelector, (communityList: CommunityListState) => communityList.loadingNode);
export const MAX_COMCOLS_PER_PAGE = 20;
/** /**
* Service class for the community list, responsible for the creating of the flat list used by communityList dataSource * Service class for the community list, responsible for the creating of the flat list used by communityList dataSource
* and connection to the store to retrieve and save the state of the community list * and connection to the store to retrieve and save the state of the community list
@@ -89,8 +88,15 @@ export const MAX_COMCOLS_PER_PAGE = 20;
@Injectable() @Injectable()
export class CommunityListService { export class CommunityListService {
constructor(private communityDataService: CommunityDataService, private collectionDataService: CollectionDataService, private pageSize: number;
private store: Store<any>) {
constructor(
@Inject(APP_CONFIG) protected appConfig: AppConfig,
private communityDataService: CommunityDataService,
private collectionDataService: CollectionDataService,
private store: Store<any>
) {
this.pageSize = appConfig.communityList.pageSize;
} }
private configOnePage: FindListOptions = Object.assign(new FindListOptions(), { private configOnePage: FindListOptions = Object.assign(new FindListOptions(), {
@@ -145,7 +151,7 @@ export class CommunityListService {
private getTopCommunities(options: FindListOptions): Observable<PaginatedList<Community>> { private getTopCommunities(options: FindListOptions): Observable<PaginatedList<Community>> {
return this.communityDataService.findTop({ return this.communityDataService.findTop({
currentPage: options.currentPage, currentPage: options.currentPage,
elementsPerPage: MAX_COMCOLS_PER_PAGE, elementsPerPage: this.pageSize,
sort: { sort: {
field: options.sort.field, field: options.sort.field,
direction: options.sort.direction direction: options.sort.direction
@@ -216,7 +222,7 @@ export class CommunityListService {
let subcoms = []; let subcoms = [];
for (let i = 1; i <= currentCommunityPage; i++) { for (let i = 1; i <= currentCommunityPage; i++) {
const nextSetOfSubcommunitiesPage = this.communityDataService.findByParent(community.uuid, { const nextSetOfSubcommunitiesPage = this.communityDataService.findByParent(community.uuid, {
elementsPerPage: MAX_COMCOLS_PER_PAGE, elementsPerPage: this.pageSize,
currentPage: i currentPage: i
}, },
followLink('subcommunities', { findListOptions: this.configOnePage }), followLink('subcommunities', { findListOptions: this.configOnePage }),
@@ -241,7 +247,7 @@ export class CommunityListService {
let collections = []; let collections = [];
for (let i = 1; i <= currentCollectionPage; i++) { for (let i = 1; i <= currentCollectionPage; i++) {
const nextSetOfCollectionsPage = this.collectionDataService.findByParent(community.uuid, { const nextSetOfCollectionsPage = this.collectionDataService.findByParent(community.uuid, {
elementsPerPage: MAX_COMCOLS_PER_PAGE, elementsPerPage: this.pageSize,
currentPage: i currentPage: i
}) })
.pipe( .pipe(

View File

@@ -135,7 +135,7 @@ describe('CommunityPageSubCollectionList Component', () => {
}); });
const groupDataService = jasmine.createSpyObj('groupsDataService', { const groupDataService = jasmine.createSpyObj('groupsDataService', {
findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])),
getGroupRegistryRouterLink: '', getGroupRegistryRouterLink: '',
getUUIDFromString: '', getUUIDFromString: '',
}); });

View File

@@ -131,7 +131,7 @@ describe('CommunityPageSubCommunityListComponent Component', () => {
}); });
const groupDataService = jasmine.createSpyObj('groupsDataService', { const groupDataService = jasmine.createSpyObj('groupsDataService', {
findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])),
getGroupRegistryRouterLink: '', getGroupRegistryRouterLink: '',
getUUIDFromString: '', getUUIDFromString: '',
}); });

View File

@@ -7,17 +7,24 @@ import { TestScheduler } from 'rxjs/testing';
import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils';
import { ShortLivedToken } from './models/short-lived-token.model'; import { ShortLivedToken } from './models/short-lived-token.model';
import { RemoteData } from '../data/remote-data'; import { RemoteData } from '../data/remote-data';
import { HttpOptions } from '../dspace-rest/dspace-rest.service';
import objectContaining = jasmine.objectContaining;
import { AuthStatus } from './models/auth-status.model';
import { RestRequestMethod } from '../data/rest-request-method';
describe(`AuthRequestService`, () => { describe(`AuthRequestService`, () => {
let halService: HALEndpointService; let halService: HALEndpointService;
let endpointURL: string; let endpointURL: string;
let requestID: string;
let shortLivedToken: ShortLivedToken; let shortLivedToken: ShortLivedToken;
let shortLivedTokenRD: RemoteData<ShortLivedToken>; let shortLivedTokenRD: RemoteData<ShortLivedToken>;
let requestService: RequestService; let requestService: RequestService;
let rdbService: RemoteDataBuildService; let rdbService: RemoteDataBuildService;
let service: AuthRequestService; let service;
let testScheduler; let testScheduler;
const status = new AuthStatus();
class TestAuthRequestService extends AuthRequestService { class TestAuthRequestService extends AuthRequestService {
constructor( constructor(
hes: HALEndpointService, hes: HALEndpointService,
@@ -34,6 +41,7 @@ describe(`AuthRequestService`, () => {
const init = (cold: typeof TestScheduler.prototype.createColdObservable) => { const init = (cold: typeof TestScheduler.prototype.createColdObservable) => {
endpointURL = 'https://rest.api/auth'; endpointURL = 'https://rest.api/auth';
requestID = 'requestID';
shortLivedToken = Object.assign(new ShortLivedToken(), { shortLivedToken = Object.assign(new ShortLivedToken(), {
value: 'some-token' value: 'some-token'
}); });
@@ -43,13 +51,16 @@ describe(`AuthRequestService`, () => {
'getEndpoint': cold('a', { a: endpointURL }) 'getEndpoint': cold('a', { a: endpointURL })
}); });
requestService = jasmine.createSpyObj('requestService', { requestService = jasmine.createSpyObj('requestService', {
'send': null 'generateRequestId': requestID,
'send': null,
}); });
rdbService = jasmine.createSpyObj('rdbService', { rdbService = jasmine.createSpyObj('rdbService', {
'buildFromRequestUUID': cold('a', { a: shortLivedTokenRD }) 'buildFromRequestUUID': cold('a', { a: shortLivedTokenRD })
}); });
service = new TestAuthRequestService(halService, requestService, rdbService); service = new TestAuthRequestService(halService, requestService, rdbService);
spyOn(service as any, 'fetchRequest').and.returnValue(cold('a', { a: createSuccessfulRemoteDataObject(status) }));
}; };
beforeEach(() => { beforeEach(() => {
@@ -58,6 +69,94 @@ describe(`AuthRequestService`, () => {
}); });
}); });
describe('REST request methods', () => {
let options: HttpOptions;
beforeEach(() => {
options = Object.create({});
});
describe('GET', () => {
it('should send a GET request to the right endpoint and return the auth status', () => {
testScheduler.run(({ cold, expectObservable, flush }) => {
init(cold);
expectObservable(service.getRequest('method', options)).toBe('a', {
a: objectContaining({ payload: status }),
});
flush();
expect(requestService.send).toHaveBeenCalledWith(objectContaining({
uuid: requestID,
href: endpointURL + '/method',
method: RestRequestMethod.GET,
body: undefined,
options,
}));
expect((service as any).fetchRequest).toHaveBeenCalledWith(requestID);
});
});
it('should send the request even if caller doesn\'t subscribe to the response', () => {
testScheduler.run(({ cold, flush }) => {
init(cold);
service.getRequest('method', options);
flush();
expect(requestService.send).toHaveBeenCalledWith(objectContaining({
uuid: requestID,
href: endpointURL + '/method',
method: RestRequestMethod.GET,
body: undefined,
options,
}));
expect((service as any).fetchRequest).toHaveBeenCalledWith(requestID);
});
});
});
describe('POST', () => {
it('should send a POST request to the right endpoint and return the auth status', () => {
testScheduler.run(({ cold, expectObservable, flush }) => {
init(cold);
expectObservable(service.postToEndpoint('method', { content: 'something' }, options)).toBe('a', {
a: objectContaining({ payload: status }),
});
flush();
expect(requestService.send).toHaveBeenCalledWith(objectContaining({
uuid: requestID,
href: endpointURL + '/method',
method: RestRequestMethod.POST,
body: { content: 'something' },
options,
}));
expect((service as any).fetchRequest).toHaveBeenCalledWith(requestID);
});
});
it('should send the request even if caller doesn\'t subscribe to the response', () => {
testScheduler.run(({ cold, flush }) => {
init(cold);
service.postToEndpoint('method', { content: 'something' }, options);
flush();
expect(requestService.send).toHaveBeenCalledWith(objectContaining({
uuid: requestID,
href: endpointURL + '/method',
method: RestRequestMethod.POST,
body: { content: 'something' },
options,
}));
expect((service as any).fetchRequest).toHaveBeenCalledWith(requestID);
});
});
});
});
describe(`getShortlivedToken`, () => { describe(`getShortlivedToken`, () => {
it(`should call createShortLivedTokenRequest with the url for the endpoint`, () => { it(`should call createShortLivedTokenRequest with the url for the endpoint`, () => {
testScheduler.run(({ cold, expectObservable, flush }) => { testScheduler.run(({ cold, expectObservable, flush }) => {

View File

@@ -1,5 +1,5 @@
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { distinctUntilChanged, filter, map, mergeMap, switchMap, tap } from 'rxjs/operators'; import { distinctUntilChanged, filter, map, switchMap, tap, take } from 'rxjs/operators';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { RequestService } from '../data/request.service'; import { RequestService } from '../data/request.service';
import { isNotEmpty } from '../../shared/empty.util'; import { isNotEmpty } from '../../shared/empty.util';
@@ -27,8 +27,13 @@ export abstract class AuthRequestService {
) { ) {
} }
protected fetchRequest(request: RestRequest, ...linksToFollow: FollowLinkConfig<AuthStatus>[]): Observable<RemoteData<AuthStatus>> { /**
return this.rdbService.buildFromRequestUUID<AuthStatus>(request.uuid, ...linksToFollow).pipe( * Fetch the response to a request from the cache, once it's completed.
* @param requestId the UUID of the request for which to retrieve the response
* @protected
*/
protected fetchRequest(requestId: string, ...linksToFollow: FollowLinkConfig<AuthStatus>[]): Observable<RemoteData<AuthStatus>> {
return this.rdbService.buildFromRequestUUID<AuthStatus>(requestId, ...linksToFollow).pipe(
getFirstCompletedRemoteData(), getFirstCompletedRemoteData(),
); );
} }
@@ -44,28 +49,48 @@ export abstract class AuthRequestService {
return url; return url;
} }
/**
* Send a POST request to an authentication endpoint
* @param method the method to send to (e.g. 'status')
* @param body the data to send (optional)
* @param options the HTTP options for the request
*/
public postToEndpoint(method: string, body?: any, options?: HttpOptions): Observable<RemoteData<AuthStatus>> { public postToEndpoint(method: string, body?: any, options?: HttpOptions): Observable<RemoteData<AuthStatus>> {
return this.halService.getEndpoint(this.linkName).pipe( const requestId = this.requestService.generateRequestId();
this.halService.getEndpoint(this.linkName).pipe(
filter((href: string) => isNotEmpty(href)), filter((href: string) => isNotEmpty(href)),
map((endpointURL) => this.getEndpointByMethod(endpointURL, method)), map((endpointURL) => this.getEndpointByMethod(endpointURL, method)),
distinctUntilChanged(), distinctUntilChanged(),
map((endpointURL: string) => new PostRequest(this.requestService.generateRequestId(), endpointURL, body, options)), map((endpointURL: string) => new PostRequest(requestId, endpointURL, body, options)),
tap((request: PostRequest) => this.requestService.send(request)), take(1)
mergeMap((request: PostRequest) => this.fetchRequest(request)), ).subscribe((request: PostRequest) => {
distinctUntilChanged()); this.requestService.send(request);
});
return this.fetchRequest(requestId);
} }
/**
* Send a GET request to an authentication endpoint
* @param method the method to send to (e.g. 'status')
* @param options the HTTP options for the request
*/
public getRequest(method: string, options?: HttpOptions, ...linksToFollow: FollowLinkConfig<any>[]): Observable<RemoteData<AuthStatus>> { public getRequest(method: string, options?: HttpOptions, ...linksToFollow: FollowLinkConfig<any>[]): Observable<RemoteData<AuthStatus>> {
return this.halService.getEndpoint(this.linkName).pipe( const requestId = this.requestService.generateRequestId();
this.halService.getEndpoint(this.linkName).pipe(
filter((href: string) => isNotEmpty(href)), filter((href: string) => isNotEmpty(href)),
map((endpointURL) => this.getEndpointByMethod(endpointURL, method, ...linksToFollow)), map((endpointURL) => this.getEndpointByMethod(endpointURL, method, ...linksToFollow)),
distinctUntilChanged(), distinctUntilChanged(),
map((endpointURL: string) => new GetRequest(this.requestService.generateRequestId(), endpointURL, undefined, options)), map((endpointURL: string) => new GetRequest(requestId, endpointURL, undefined, options)),
tap((request: GetRequest) => this.requestService.send(request)), take(1)
mergeMap((request: GetRequest) => this.fetchRequest(request, ...linksToFollow)), ).subscribe((request: GetRequest) => {
distinctUntilChanged()); this.requestService.send(request);
} });
return this.fetchRequest(requestId, ...linksToFollow);
}
/** /**
* Factory function to create the request object to send. This needs to be a POST client side and * Factory function to create the request object to send. This needs to be a POST client side and
* a GET server side. Due to CSRF validation, the server isn't allowed to send a POST, so we allow * a GET server side. Due to CSRF validation, the server isn't allowed to send a POST, so we allow

View File

@@ -17,6 +17,7 @@ import {
import { AuthTokenInfo } from './models/auth-token-info.model'; import { AuthTokenInfo } from './models/auth-token-info.model';
import { AuthMethod } from './models/auth.method'; import { AuthMethod } from './models/auth.method';
import { AuthMethodType } from './models/auth.method-type'; import { AuthMethodType } from './models/auth.method-type';
import { StoreActionTypes } from '../../store.actions';
/** /**
* The auth state. * The auth state.
@@ -251,6 +252,11 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
idle: false, idle: false,
}); });
case StoreActionTypes.REHYDRATE:
return Object.assign({}, state, {
blocking: true,
});
default: default:
return state; return state;
} }

View File

@@ -4,7 +4,7 @@ import { HttpHeaders } from '@angular/common/http';
import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens';
import { Observable, of as observableOf } from 'rxjs'; import { Observable, of as observableOf } from 'rxjs';
import { map, startWith, switchMap, take } from 'rxjs/operators'; import { filter, map, startWith, switchMap, take } from 'rxjs/operators';
import { select, Store } from '@ngrx/store'; import { select, Store } from '@ngrx/store';
import { CookieAttributes } from 'js-cookie'; import { CookieAttributes } from 'js-cookie';
@@ -93,6 +93,8 @@ export class AuthService {
private translateService: TranslateService private translateService: TranslateService
) { ) {
this.store.pipe( this.store.pipe(
// when this service is constructed the store is not fully initialized yet
filter((state: any) => state?.core?.auth !== undefined),
select(isAuthenticated), select(isAuthenticated),
startWith(false) startWith(false)
).subscribe((authenticated: boolean) => this._authenticated = authenticated); ).subscribe((authenticated: boolean) => this._authenticated = authenticated);
@@ -346,7 +348,7 @@ export class AuthService {
let token: AuthTokenInfo; let token: AuthTokenInfo;
let currentlyRefreshingToken = false; let currentlyRefreshingToken = false;
this.store.pipe(select(getAuthenticationToken)).subscribe((authTokenInfo: AuthTokenInfo) => { this.store.pipe(select(getAuthenticationToken)).subscribe((authTokenInfo: AuthTokenInfo) => {
// If new token is undefined an it wasn't previously => Refresh failed // If new token is undefined and it wasn't previously => Refresh failed
if (currentlyRefreshingToken && token !== undefined && authTokenInfo === undefined) { if (currentlyRefreshingToken && token !== undefined && authTokenInfo === undefined) {
// Token refresh failed => Error notification => 10 second wait => Page reloads & user logged out // Token refresh failed => Error notification => 10 second wait => Page reloads & user logged out
this.notificationService.error(this.translateService.get('auth.messages.token-refresh-failed')); this.notificationService.error(this.translateService.get('auth.messages.token-refresh-failed'));

View File

@@ -2,23 +2,26 @@ import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
import { DSOBreadcrumbsService } from './dso-breadcrumbs.service'; import { DSOBreadcrumbsService } from './dso-breadcrumbs.service';
import { DataService } from '../data/data.service'; import { getFirstCompletedRemoteData, getRemoteDataPayload } from '../shared/operators';
import { getRemoteDataPayload, getFirstCompletedRemoteData } from '../shared/operators';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { DSpaceObject } from '../shared/dspace-object.model'; import { DSpaceObject } from '../shared/dspace-object.model';
import { ChildHALResource } from '../shared/child-hal-resource.model'; import { ChildHALResource } from '../shared/child-hal-resource.model';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { hasValue } from '../../shared/empty.util'; import { hasValue } from '../../shared/empty.util';
import { IdentifiableDataService } from '../data/base/identifiable-data.service';
/** /**
* The class that resolves the BreadcrumbConfig object for a DSpaceObject * The class that resolves the BreadcrumbConfig object for a DSpaceObject
*/ */
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
export abstract class DSOBreadcrumbResolver<T extends ChildHALResource & DSpaceObject> implements Resolve<BreadcrumbConfig<T>> { export abstract class DSOBreadcrumbResolver<T extends ChildHALResource & DSpaceObject> implements Resolve<BreadcrumbConfig<T>> {
constructor(protected breadcrumbService: DSOBreadcrumbsService, protected dataService: DataService<T>) { protected constructor(
protected breadcrumbService: DSOBreadcrumbsService,
protected dataService: IdentifiableDataService<T>,
) {
} }
/** /**
@@ -36,7 +39,7 @@ export abstract class DSOBreadcrumbResolver<T extends ChildHALResource & DSpaceO
if (hasValue(object)) { if (hasValue(object)) {
const fullPath = state.url; const fullPath = state.url;
const url = fullPath.substr(0, fullPath.indexOf(uuid)) + uuid; const url = fullPath.substr(0, fullPath.indexOf(uuid)) + uuid;
return {provider: this.breadcrumbService, key: object, url: url}; return { provider: this.breadcrumbService, key: object, url: url };
} else { } else {
return undefined; return undefined;
} }

View File

@@ -5,15 +5,9 @@ import { FindListOptions } from '../data/find-list-options.model';
describe(`BrowseDefinitionDataService`, () => { describe(`BrowseDefinitionDataService`, () => {
let service: BrowseDefinitionDataService; let service: BrowseDefinitionDataService;
const dataServiceImplSpy = jasmine.createSpyObj('dataService', { const findAllDataSpy = jasmine.createSpyObj('findAllData', {
findAll: EMPTY, findAll: EMPTY,
findByHref: EMPTY,
findAllByHref: EMPTY,
findById: EMPTY,
}); });
const hrefAll = 'https://rest.api/server/api/discover/browses';
const hrefSingle = 'https://rest.api/server/api/discover/browses/author';
const id = 'author';
const options = new FindListOptions(); const options = new FindListOptions();
const linksToFollow = [ const linksToFollow = [
followLink('entries'), followLink('entries'),
@@ -21,35 +15,14 @@ describe(`BrowseDefinitionDataService`, () => {
]; ];
beforeEach(() => { beforeEach(() => {
service = new BrowseDefinitionDataService(null, null, null, null, null, null, null, null); service = new BrowseDefinitionDataService(null, null, null, null);
(service as any).dataService = dataServiceImplSpy; (service as any).findAllData = findAllDataSpy;
}); });
describe(`findAll`, () => { describe(`findAll`, () => {
it(`should call findAll on DataServiceImpl`, () => { it(`should call findAll on findAllData`, () => {
service.findAll(options, true, false, ...linksToFollow); service.findAll(options, true, false, ...linksToFollow);
expect(dataServiceImplSpy.findAll).toHaveBeenCalledWith(options, true, false, ...linksToFollow); expect(findAllDataSpy.findAll).toHaveBeenCalledWith(options, true, false, ...linksToFollow);
});
});
describe(`findByHref`, () => {
it(`should call findByHref on DataServiceImpl`, () => {
service.findByHref(hrefSingle, true, false, ...linksToFollow);
expect(dataServiceImplSpy.findByHref).toHaveBeenCalledWith(hrefSingle, true, false, ...linksToFollow);
});
});
describe(`findAllByHref`, () => {
it(`should call findAllByHref on DataServiceImpl`, () => {
service.findAllByHref(hrefAll, options, true, false, ...linksToFollow);
expect(dataServiceImplSpy.findAllByHref).toHaveBeenCalledWith(hrefAll, options, true, false, ...linksToFollow);
});
});
describe(`findById`, () => {
it(`should call findById on DataServiceImpl`, () => {
service.findAllByHref(id, options, true, false, ...linksToFollow);
expect(dataServiceImplSpy.findAllByHref).toHaveBeenCalledWith(id, options, true, false, ...linksToFollow);
}); });
}); });
}); });

View File

@@ -1,61 +1,38 @@
/* eslint-disable max-classes-per-file */
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { dataService } from '../cache/builders/build-decorators';
import { BROWSE_DEFINITION } from '../shared/browse-definition.resource-type'; import { BROWSE_DEFINITION } from '../shared/browse-definition.resource-type';
import { DataService } from '../data/data.service';
import { BrowseDefinition } from '../shared/browse-definition.model'; import { BrowseDefinition } from '../shared/browse-definition.model';
import { RequestService } from '../data/request.service'; import { RequestService } from '../data/request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { Store } from '@ngrx/store';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { RemoteData } from '../data/remote-data'; import { RemoteData } from '../data/remote-data';
import { PaginatedList } from '../data/paginated-list.model'; import { PaginatedList } from '../data/paginated-list.model';
import { CoreState } from '../core-state.model';
import { FindListOptions } from '../data/find-list-options.model'; import { FindListOptions } from '../data/find-list-options.model';
import { IdentifiableDataService } from '../data/base/identifiable-data.service';
import { FindAllData, FindAllDataImpl } from '../data/base/find-all-data';
import { dataService } from '../data/base/data-service.decorator';
/**
class DataServiceImpl extends DataService<BrowseDefinition> { * Data service responsible for retrieving browse definitions from the REST server
protected linkPath = 'browses'; */
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DefaultChangeAnalyzer<BrowseDefinition>) {
super();
}
}
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
@dataService(BROWSE_DEFINITION) @dataService(BROWSE_DEFINITION)
export class BrowseDefinitionDataService { export class BrowseDefinitionDataService extends IdentifiableDataService<BrowseDefinition> implements FindAllData<BrowseDefinition> {
/** private findAllData: FindAllDataImpl<BrowseDefinition>;
* A private DataService instance to delegate specific methods to.
*/
private dataService: DataServiceImpl;
constructor( constructor(
protected requestService: RequestService, protected requestService: RequestService,
protected rdbService: RemoteDataBuildService, protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
protected halService: HALEndpointService, protected halService: HALEndpointService,
protected notificationsService: NotificationsService, ) {
protected http: HttpClient, super('browses', requestService, rdbService, objectCache, halService);
protected comparator: DefaultChangeAnalyzer<BrowseDefinition>) {
this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
} }
/** /**
@@ -69,57 +46,11 @@ export class BrowseDefinitionDataService {
* requested after the response becomes stale * requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which * @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved * {@link HALLink}s should be automatically resolved
* @return {Observable<RemoteData<PaginatedList<BrowseDefinition>>>} * @return {Observable<RemoteData<PaginatedList<T>>>}
* Return an observable that emits object list * Return an observable that emits object list
*/ */
findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<BrowseDefinition>[]): Observable<RemoteData<PaginatedList<BrowseDefinition>>> { findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<BrowseDefinition>[]): Observable<RemoteData<PaginatedList<BrowseDefinition>>> {
return this.dataService.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Returns an observable of {@link RemoteData} of an {@link BrowseDefinition}, based on an href, with a list of {@link FollowLinkConfig},
* to automatically resolve {@link HALLink}s of the {@link BrowseDefinition}
* @param href The url of {@link BrowseDefinition} we want to retrieve
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
*/
findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<BrowseDefinition>[]): Observable<RemoteData<BrowseDefinition>> {
return this.dataService.findByHref(href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Returns a list of observables of {@link RemoteData} of {@link BrowseDefinition}s, based on an href, with a list of {@link FollowLinkConfig},
* to automatically resolve {@link HALLink}s of the {@link BrowseDefinition}
* @param href The url of object we want to retrieve
* @param findListOptions Find list options object
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
*/
findAllByHref(href: string, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<BrowseDefinition>[]): Observable<RemoteData<PaginatedList<BrowseDefinition>>> {
return this.dataService.findAllByHref(href, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Returns an observable of {@link RemoteData} of an object, based on its ID, with a list of
* {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object
* @param id ID of object we want to retrieve
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
*/
findById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<BrowseDefinition>[]): Observable<RemoteData<BrowseDefinition>> {
return this.dataService.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
} }
} }

View File

@@ -139,13 +139,13 @@ describe('BrowseService', () => {
}); });
describe('when getBrowseEntriesFor is called with a valid browse definition id', () => { describe('when getBrowseEntriesFor is called with a valid browse definition id', () => {
it('should call hrefOnlyDataService.findAllByHref with the expected href', () => { it('should call hrefOnlyDataService.findListByHref with the expected href', () => {
const expected = browseDefinitions[1]._links.entries.href; const expected = browseDefinitions[1]._links.entries.href;
scheduler.schedule(() => service.getBrowseEntriesFor(new BrowseEntrySearchOptions(browseDefinitions[1].id)).subscribe()); scheduler.schedule(() => service.getBrowseEntriesFor(new BrowseEntrySearchOptions(browseDefinitions[1].id)).subscribe());
scheduler.flush(); scheduler.flush();
expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findAllByHref)).toBeObservable(cold('(a|)', { expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findListByHref)).toBeObservable(cold('(a|)', {
a: expected a: expected
})); }));
}); });
@@ -153,20 +153,20 @@ describe('BrowseService', () => {
}); });
describe('when findList is called with a valid browse definition id', () => { describe('when findList is called with a valid browse definition id', () => {
it('should call hrefOnlyDataService.findAllByHref with the expected href', () => { it('should call hrefOnlyDataService.findListByHref with the expected href', () => {
const expected = browseDefinitions[1]._links.items.href + '?filterValue=' + encodeURIComponent(mockAuthorName); const expected = browseDefinitions[1]._links.items.href + '?filterValue=' + encodeURIComponent(mockAuthorName);
scheduler.schedule(() => service.getBrowseItemsFor(mockAuthorName, undefined, new BrowseEntrySearchOptions(browseDefinitions[1].id)).subscribe()); scheduler.schedule(() => service.getBrowseItemsFor(mockAuthorName, undefined, new BrowseEntrySearchOptions(browseDefinitions[1].id)).subscribe());
scheduler.flush(); scheduler.flush();
expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findAllByHref)).toBeObservable(cold('(a|)', { expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findListByHref)).toBeObservable(cold('(a|)', {
a: expected a: expected
})); }));
}); });
}); });
describe('when getBrowseItemsFor is called with a valid filter value and authority key', () => { describe('when getBrowseItemsFor is called with a valid filter value and authority key', () => {
it('should call hrefOnlyDataService.findAllByHref with the expected href', () => { it('should call hrefOnlyDataService.findListByHref with the expected href', () => {
const expected = browseDefinitions[1]._links.items.href + const expected = browseDefinitions[1]._links.items.href +
'?filterValue=' + encodeURIComponent(mockAuthorName) + '?filterValue=' + encodeURIComponent(mockAuthorName) +
'&filterAuthority=' + encodeURIComponent(mockAuthorityKey); '&filterAuthority=' + encodeURIComponent(mockAuthorityKey);
@@ -174,7 +174,7 @@ describe('BrowseService', () => {
scheduler.schedule(() => service.getBrowseItemsFor(mockAuthorName, mockAuthorityKey, new BrowseEntrySearchOptions(browseDefinitions[1].id)).subscribe()); scheduler.schedule(() => service.getBrowseItemsFor(mockAuthorName, mockAuthorityKey, new BrowseEntrySearchOptions(browseDefinitions[1].id)).subscribe());
scheduler.flush(); scheduler.flush();
expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findAllByHref)).toBeObservable(cold('(a|)', { expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findListByHref)).toBeObservable(cold('(a|)', {
a: expected a: expected
})); }));
}); });
@@ -267,11 +267,11 @@ describe('BrowseService', () => {
describe('when getFirstItemFor is called with a valid browse definition id', () => { describe('when getFirstItemFor is called with a valid browse definition id', () => {
const expectedURL = browseDefinitions[1]._links.items.href + '?page=0&size=1'; const expectedURL = browseDefinitions[1]._links.items.href + '?page=0&size=1';
it('should call hrefOnlyDataService.findAllByHref with the expected href', () => { it('should call hrefOnlyDataService.findListByHref with the expected href', () => {
scheduler.schedule(() => service.getFirstItemFor(browseDefinitions[1].id).subscribe()); scheduler.schedule(() => service.getFirstItemFor(browseDefinitions[1].id).subscribe());
scheduler.flush(); scheduler.flush();
expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findAllByHref)).toBeObservable(cold('(a|)', { expect(getFirstUsedArgumentOfSpyMethod(hrefOnlyDataService.findListByHref)).toBeObservable(cold('(a|)', {
a: expectedURL a: expectedURL
})); }));
}); });

View File

@@ -96,7 +96,7 @@ export class BrowseService {
return href; return href;
}) })
); );
return this.hrefOnlyDataService.findAllByHref<BrowseEntry>(href$); return this.hrefOnlyDataService.findListByHref<BrowseEntry>(href$);
} }
/** /**
@@ -141,7 +141,7 @@ export class BrowseService {
return href; return href;
}), }),
); );
return this.hrefOnlyDataService.findAllByHref<Item>(href$); return this.hrefOnlyDataService.findListByHref<Item>(href$);
} }
/** /**
@@ -172,7 +172,7 @@ export class BrowseService {
}) })
); );
return this.hrefOnlyDataService.findAllByHref<Item>(href$).pipe( return this.hrefOnlyDataService.findListByHref<Item>(href$).pipe(
getFirstSucceededRemoteData(), getFirstSucceededRemoteData(),
getFirstOccurrence() getFirstOccurrence()
); );
@@ -184,7 +184,7 @@ export class BrowseService {
* @param items * @param items
*/ */
getPrevBrowseItems(items: RemoteData<PaginatedList<Item>>): Observable<RemoteData<PaginatedList<Item>>> { getPrevBrowseItems(items: RemoteData<PaginatedList<Item>>): Observable<RemoteData<PaginatedList<Item>>> {
return this.hrefOnlyDataService.findAllByHref<Item>(items.payload.prev); return this.hrefOnlyDataService.findListByHref<Item>(items.payload.prev);
} }
/** /**
@@ -192,7 +192,7 @@ export class BrowseService {
* @param items * @param items
*/ */
getNextBrowseItems(items: RemoteData<PaginatedList<Item>>): Observable<RemoteData<PaginatedList<Item>>> { getNextBrowseItems(items: RemoteData<PaginatedList<Item>>): Observable<RemoteData<PaginatedList<Item>>> {
return this.hrefOnlyDataService.findAllByHref<Item>(items.payload.next); return this.hrefOnlyDataService.findListByHref<Item>(items.payload.next);
} }
/** /**
@@ -200,7 +200,7 @@ export class BrowseService {
* @param entries * @param entries
*/ */
getPrevBrowseEntries(entries: RemoteData<PaginatedList<BrowseEntry>>): Observable<RemoteData<PaginatedList<BrowseEntry>>> { getPrevBrowseEntries(entries: RemoteData<PaginatedList<BrowseEntry>>): Observable<RemoteData<PaginatedList<BrowseEntry>>> {
return this.hrefOnlyDataService.findAllByHref<BrowseEntry>(entries.payload.prev); return this.hrefOnlyDataService.findListByHref<BrowseEntry>(entries.payload.prev);
} }
/** /**
@@ -208,7 +208,7 @@ export class BrowseService {
* @param entries * @param entries
*/ */
getNextBrowseEntries(entries: RemoteData<PaginatedList<BrowseEntry>>): Observable<RemoteData<PaginatedList<BrowseEntry>>> { getNextBrowseEntries(entries: RemoteData<PaginatedList<BrowseEntry>>): Observable<RemoteData<PaginatedList<BrowseEntry>>> {
return this.hrefOnlyDataService.findAllByHref<BrowseEntry>(entries.payload.next); return this.hrefOnlyDataService.findListByHref<BrowseEntry>(entries.payload.next);
} }
/** /**

View File

@@ -1,14 +1,7 @@
/* eslint-disable max-classes-per-file */
import { HALLink } from '../../shared/hal-link.model'; import { HALLink } from '../../shared/hal-link.model';
import { HALResource } from '../../shared/hal-resource.model'; import { HALResource } from '../../shared/hal-resource.model';
import { ResourceType } from '../../shared/resource-type'; import { ResourceType } from '../../shared/resource-type';
import { dataService, getDataServiceFor, getLinkDefinition, link, } from './build-decorators'; import { getLinkDefinition, link } from './build-decorators';
class TestService {
}
class AnotherTestService {
}
class TestHALResource implements HALResource { class TestHALResource implements HALResource {
_links: { _links: {
@@ -25,31 +18,6 @@ describe('build decorators', () => {
beforeEach(() => { beforeEach(() => {
testType = new ResourceType('testType-' + new Date().getTime()); testType = new ResourceType('testType-' + new Date().getTime());
}); });
describe('@dataService/getDataServiceFor', () => {
it('should register a resourcetype for a dataservice', () => {
dataService(testType)(TestService);
expect(getDataServiceFor(testType)).toBe(TestService);
});
describe(`when the resource type isn't specified`, () => {
it(`should throw an error`, () => {
expect(() => {
dataService(undefined)(TestService);
}).toThrow();
});
});
describe(`when there already is a registered dataservice for a resourcetype`, () => {
it(`should throw an error`, () => {
dataService(testType)(TestService);
expect(() => {
dataService(testType)(AnotherTestService);
}).toThrow();
});
});
});
describe(`@link/getLinkDefinitions`, () => { describe(`@link/getLinkDefinitions`, () => {
it(`should register a link`, () => { it(`should register a link`, () => {

View File

@@ -3,20 +3,13 @@ import { hasNoValue, hasValue } from '../../../shared/empty.util';
import { GenericConstructor } from '../../shared/generic-constructor'; import { GenericConstructor } from '../../shared/generic-constructor';
import { HALResource } from '../../shared/hal-resource.model'; import { HALResource } from '../../shared/hal-resource.model';
import { ResourceType } from '../../shared/resource-type'; import { ResourceType } from '../../shared/resource-type';
import { import { getResourceTypeValueFor } from '../object-cache.reducer';
getResourceTypeValueFor
} from '../object-cache.reducer';
import { InjectionToken } from '@angular/core'; import { InjectionToken } from '@angular/core';
import { CacheableObject } from '../cacheable-object.model';
import { TypedObject } from '../typed-object.model'; import { TypedObject } from '../typed-object.model';
export const DATA_SERVICE_FACTORY = new InjectionToken<(resourceType: ResourceType) => GenericConstructor<any>>('getDataServiceFor', {
providedIn: 'root',
factory: () => getDataServiceFor
});
export const LINK_DEFINITION_FACTORY = new InjectionToken<<T extends HALResource>(source: GenericConstructor<T>, linkName: keyof T['_links']) => LinkDefinition<T>>('getLinkDefinition', { export const LINK_DEFINITION_FACTORY = new InjectionToken<<T extends HALResource>(source: GenericConstructor<T>, linkName: keyof T['_links']) => LinkDefinition<T>>('getLinkDefinition', {
providedIn: 'root', providedIn: 'root',
factory: () => getLinkDefinition factory: () => getLinkDefinition,
}); });
export const LINK_DEFINITION_MAP_FACTORY = new InjectionToken<<T extends HALResource>(source: GenericConstructor<T>) => Map<keyof T['_links'], LinkDefinition<T>>>('getLinkDefinitions', { export const LINK_DEFINITION_MAP_FACTORY = new InjectionToken<<T extends HALResource>(source: GenericConstructor<T>) => Map<keyof T['_links'], LinkDefinition<T>>>('getLinkDefinitions', {
providedIn: 'root', providedIn: 'root',
@@ -27,7 +20,6 @@ const resolvedLinkKey = Symbol('resolvedLink');
const resolvedLinkMap = new Map(); const resolvedLinkMap = new Map();
const typeMap = new Map(); const typeMap = new Map();
const dataServiceMap = new Map();
const linkMap = new Map(); const linkMap = new Map();
/** /**
@@ -46,39 +38,6 @@ export function getClassForType(type: string | ResourceType) {
return typeMap.get(getResourceTypeValueFor(type)); return typeMap.get(getResourceTypeValueFor(type));
} }
/**
* A class decorator to indicate that this class is a dataservice
* for a given resource type.
*
* "dataservice" in this context means that it has findByHref and
* findAllByHref methods.
*
* @param resourceType the resource type the class is a dataservice for
*/
export function dataService(resourceType: ResourceType): any {
return (target: any) => {
if (hasNoValue(resourceType)) {
throw new Error(`Invalid @dataService annotation on ${target}, resourceType needs to be defined`);
}
const existingDataservice = dataServiceMap.get(resourceType.value);
if (hasValue(existingDataservice)) {
throw new Error(`Multiple dataservices for ${resourceType.value}: ${existingDataservice} and ${target}`);
}
dataServiceMap.set(resourceType.value, target);
};
}
/**
* Return the dataservice matching the given resource type
*
* @param resourceType the resource type you want the matching dataservice for
*/
export function getDataServiceFor<T extends CacheableObject>(resourceType: ResourceType) {
return dataServiceMap.get(resourceType.value);
}
/** /**
* A class to represent the data that can be set by the @link decorator * A class to represent the data that can be set by the @link decorator
*/ */

View File

@@ -6,9 +6,10 @@ import { HALLink } from '../../shared/hal-link.model';
import { HALResource } from '../../shared/hal-resource.model'; import { HALResource } from '../../shared/hal-resource.model';
import { ResourceType } from '../../shared/resource-type'; import { ResourceType } from '../../shared/resource-type';
import { LinkService } from './link.service'; import { LinkService } from './link.service';
import { DATA_SERVICE_FACTORY, LINK_DEFINITION_FACTORY, LINK_DEFINITION_MAP_FACTORY } from './build-decorators'; import { LINK_DEFINITION_FACTORY, LINK_DEFINITION_MAP_FACTORY } from './build-decorators';
import { isEmpty } from 'rxjs/operators'; import { isEmpty } from 'rxjs/operators';
import { FindListOptions } from '../../data/find-list-options.model'; import { FindListOptions } from '../../data/find-list-options.model';
import { DATA_SERVICE_FACTORY } from '../../data/base/data-service.decorator';
const TEST_MODEL = new ResourceType('testmodel'); const TEST_MODEL = new ResourceType('testmodel');
let result: any; let result: any;
@@ -32,8 +33,8 @@ class TestModel implements HALResource {
@Injectable() @Injectable()
class TestDataService { class TestDataService {
findAllByHref(href: string, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<any>[]) { findListByHref(href: string, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<any>[]) {
return 'findAllByHref'; return 'findListByHref';
} }
findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<any>[]) { findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<any>[]) {
@@ -64,7 +65,7 @@ describe('LinkService', () => {
} }
}); });
testDataService = new TestDataService(); testDataService = new TestDataService();
spyOn(testDataService, 'findAllByHref').and.callThrough(); spyOn(testDataService, 'findListByHref').and.callThrough();
spyOn(testDataService, 'findByHref').and.callThrough(); spyOn(testDataService, 'findByHref').and.callThrough();
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [LinkService, { providers: [LinkService, {
@@ -118,8 +119,8 @@ describe('LinkService', () => {
}); });
service.resolveLink(testModel, followLink('predecessor', { findListOptions: { some: 'options ' } as any }, followLink('successor'))); service.resolveLink(testModel, followLink('predecessor', { findListOptions: { some: 'options ' } as any }, followLink('successor')));
}); });
it('should call dataservice.findAllByHref with the correct href, findListOptions, and nested links', () => { it('should call dataservice.findListByHref with the correct href, findListOptions, and nested links', () => {
expect(testDataService.findAllByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, { some: 'options ' } as any, true, true, followLink('successor')); expect(testDataService.findListByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, { some: 'options ' } as any, true, true, followLink('successor'));
}); });
}); });
describe('either way', () => { describe('either way', () => {

View File

@@ -3,28 +3,30 @@ import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util';
import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
import { GenericConstructor } from '../../shared/generic-constructor'; import { GenericConstructor } from '../../shared/generic-constructor';
import { HALResource } from '../../shared/hal-resource.model'; import { HALResource } from '../../shared/hal-resource.model';
import { DATA_SERVICE_FACTORY } from '../../data/base/data-service.decorator';
import { import {
DATA_SERVICE_FACTORY,
LINK_DEFINITION_FACTORY, LINK_DEFINITION_FACTORY,
LINK_DEFINITION_MAP_FACTORY, LINK_DEFINITION_MAP_FACTORY,
LinkDefinition LinkDefinition,
} from './build-decorators'; } from './build-decorators';
import { RemoteData } from '../../data/remote-data'; import { RemoteData } from '../../data/remote-data';
import { EMPTY, Observable } from 'rxjs'; import { EMPTY, Observable } from 'rxjs';
import { ResourceType } from '../../shared/resource-type'; import { ResourceType } from '../../shared/resource-type';
import { HALDataService } from '../../data/base/hal-data-service.interface';
import { PaginatedList } from '../../data/paginated-list.model';
/** /**
* A Service to handle the resolving and removing * A Service to handle the resolving and removing
* of resolved {@link HALLink}s on HALResources * of resolved {@link HALLink}s on HALResources
*/ */
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
export class LinkService { export class LinkService {
constructor( constructor(
protected parentInjector: Injector, protected parentInjector: Injector,
@Inject(DATA_SERVICE_FACTORY) private getDataServiceFor: (resourceType: ResourceType) => GenericConstructor<any>, @Inject(DATA_SERVICE_FACTORY) private getDataServiceFor: (resourceType: ResourceType) => GenericConstructor<HALDataService<any>>,
@Inject(LINK_DEFINITION_FACTORY) private getLinkDefinition: <T extends HALResource>(source: GenericConstructor<T>, linkName: keyof T['_links']) => LinkDefinition<T>, @Inject(LINK_DEFINITION_FACTORY) private getLinkDefinition: <T extends HALResource>(source: GenericConstructor<T>, linkName: keyof T['_links']) => LinkDefinition<T>,
@Inject(LINK_DEFINITION_MAP_FACTORY) private getLinkDefinitions: <T extends HALResource>(source: GenericConstructor<T>) => Map<keyof T['_links'], LinkDefinition<T>>, @Inject(LINK_DEFINITION_MAP_FACTORY) private getLinkDefinitions: <T extends HALResource>(source: GenericConstructor<T>) => Map<keyof T['_links'], LinkDefinition<T>>,
) { ) {
@@ -51,7 +53,7 @@ export class LinkService {
* @param model the {@link HALResource} to resolve the link for * @param model the {@link HALResource} to resolve the link for
* @param linkToFollow the {@link FollowLinkConfig} to resolve * @param linkToFollow the {@link FollowLinkConfig} to resolve
*/ */
public resolveLinkWithoutAttaching<T extends HALResource, U extends HALResource>(model, linkToFollow: FollowLinkConfig<T>): Observable<RemoteData<U>> { public resolveLinkWithoutAttaching<T extends HALResource, U extends HALResource>(model, linkToFollow: FollowLinkConfig<T>): Observable<RemoteData<U | PaginatedList<U>>> {
const matchingLinkDef = this.getLinkDefinition(model.constructor, linkToFollow.name); const matchingLinkDef = this.getLinkDefinition(model.constructor, linkToFollow.name);
if (hasValue(matchingLinkDef)) { if (hasValue(matchingLinkDef)) {
@@ -61,9 +63,9 @@ export class LinkService {
throw new Error(`The @link() for ${linkToFollow.name} on ${model.constructor.name} models uses the resource type ${matchingLinkDef.resourceType.value.toUpperCase()}, but there is no service with an @dataService(${matchingLinkDef.resourceType.value.toUpperCase()}) annotation in order to retrieve it`); throw new Error(`The @link() for ${linkToFollow.name} on ${model.constructor.name} models uses the resource type ${matchingLinkDef.resourceType.value.toUpperCase()}, but there is no service with an @dataService(${matchingLinkDef.resourceType.value.toUpperCase()}) annotation in order to retrieve it`);
} }
const service = Injector.create({ const service: HALDataService<any> = Injector.create({
providers: [], providers: [],
parent: this.parentInjector parent: this.parentInjector,
}).get(provider); }).get(provider);
const link = model._links[matchingLinkDef.linkName]; const link = model._links[matchingLinkDef.linkName];
@@ -72,7 +74,7 @@ export class LinkService {
try { try {
if (matchingLinkDef.isList) { if (matchingLinkDef.isList) {
return service.findAllByHref(href, linkToFollow.findListOptions, linkToFollow.useCachedVersionIfAvailable, linkToFollow.reRequestOnStale, ...linkToFollow.linksToFollow); return service.findListByHref(href, linkToFollow.findListOptions, linkToFollow.useCachedVersionIfAvailable, linkToFollow.reRequestOnStale, ...linkToFollow.linksToFollow);
} else { } else {
return service.findByHref(href, linkToFollow.useCachedVersionIfAvailable, linkToFollow.reRequestOnStale, ...linkToFollow.linksToFollow); return service.findByHref(href, linkToFollow.useCachedVersionIfAvailable, linkToFollow.reRequestOnStale, ...linkToFollow.linksToFollow);
} }

View File

@@ -1,7 +1,7 @@
import { getTestScheduler } from 'jasmine-marbles'; import { getTestScheduler } from 'jasmine-marbles';
import { TestScheduler } from 'rxjs/testing'; import { TestScheduler } from 'rxjs/testing';
import { getMockRequestService } from '../../shared/mocks/request.service.mock'; import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { ConfigService } from './config.service'; import { ConfigDataService } from './config-data.service';
import { RequestService } from '../data/request.service'; import { RequestService } from '../data/request.service';
import { GetRequest } from '../data/request.models'; import { GetRequest } from '../data/request.models';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
@@ -9,23 +9,26 @@ import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-servic
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock';
import { FindListOptions } from '../data/find-list-options.model'; import { FindListOptions } from '../data/find-list-options.model';
import { ObjectCacheService } from '../cache/object-cache.service';
const LINK_NAME = 'test'; const LINK_NAME = 'test';
const BROWSE = 'search/findByCollection'; const BROWSE = 'search/findByCollection';
class TestService extends ConfigService { class TestService extends ConfigDataService {
protected linkPath = LINK_NAME; protected linkPath = LINK_NAME;
protected browseEndpoint = BROWSE; protected browseEndpoint = BROWSE;
constructor( constructor(
protected requestService: RequestService, protected requestService: RequestService,
protected rdbService: RemoteDataBuildService, protected rdbService: RemoteDataBuildService,
protected halService: HALEndpointService) { protected objectCache: ObjectCacheService,
super(requestService, rdbService, null, null, halService, null, null, null, BROWSE); protected halService: HALEndpointService,
) {
super(BROWSE, requestService, rdbService, objectCache, halService);
} }
} }
describe('ConfigService', () => { describe('ConfigDataService', () => {
let scheduler: TestScheduler; let scheduler: TestScheduler;
let service: TestService; let service: TestService;
let requestService: RequestService; let requestService: RequestService;
@@ -45,7 +48,8 @@ describe('ConfigService', () => {
return new TestService( return new TestService(
requestService, requestService,
rdbService, rdbService,
halService null,
halService,
); );
} }

View File

@@ -0,0 +1,40 @@
import { Observable } from 'rxjs';
import { ConfigObject } from './models/config.model';
import { RemoteData } from '../data/remote-data';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { getFirstCompletedRemoteData } from '../shared/operators';
import { map } from 'rxjs/operators';
import { BaseDataService } from '../data/base/base-data.service';
/**
* Abstract data service to retrieve configuration objects from the REST server.
* Common logic for configuration objects should be implemented here.
*/
export abstract class ConfigDataService extends BaseDataService<ConfigObject> {
/**
* Returns an observable of {@link RemoteData} of an object, based on an href, with a list of
* {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object
*
* Throws an error if a configuration object cannot be retrieved.
*
* @param href The url of object we want to retrieve
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
*/
public findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<ConfigObject>[]): Observable<RemoteData<ConfigObject>> {
return super.findByHref(href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow).pipe(
getFirstCompletedRemoteData(),
map((rd: RemoteData<ConfigObject>) => {
if (rd.hasFailed) {
throw new Error(`Couldn't retrieve the config`);
} else {
return rd;
}
}),
);
}
}

View File

@@ -1,67 +0,0 @@
/* eslint-disable max-classes-per-file */
import { Observable } from 'rxjs';
import { RequestService } from '../data/request.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ConfigObject } from './models/config.model';
import { RemoteData } from '../data/remote-data';
import { DataService } from '../data/data.service';
import { Store } from '@ngrx/store';
import { ObjectCacheService } from '../cache/object-cache.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { getFirstCompletedRemoteData } from '../shared/operators';
import { map } from 'rxjs/operators';
import { CoreState } from '../core-state.model';
class DataServiceImpl extends DataService<ConfigObject> {
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DefaultChangeAnalyzer<ConfigObject>,
protected linkPath: string
) {
super();
}
}
export abstract class ConfigService {
/**
* A private DataService instance to delegate specific methods to.
*/
private dataService: DataServiceImpl;
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DefaultChangeAnalyzer<ConfigObject>,
protected linkPath: string
) {
this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator, this.linkPath);
}
public findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<ConfigObject>[]): Observable<RemoteData<ConfigObject>> {
return this.dataService.findByHref(href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow).pipe(
getFirstCompletedRemoteData(),
map((rd: RemoteData<ConfigObject>) => {
if (rd.hasFailed) {
throw new Error(`Couldn't retrieve the config`);
} else {
return rd;
}
})
);
}
}

View File

@@ -1,41 +1,46 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { ConfigService } from './config.service'; import { ConfigDataService } from './config-data.service';
import { RequestService } from '../data/request.service'; import { RequestService } from '../data/request.service';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { dataService } from '../cache/builders/build-decorators';
import { SUBMISSION_ACCESSES_TYPE } from './models/config-type'; import { SUBMISSION_ACCESSES_TYPE } from './models/config-type';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { Store } from '@ngrx/store';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service';
import { ConfigObject } from './models/config.model'; import { ConfigObject } from './models/config.model';
import { SubmissionAccessesModel } from './models/config-submission-accesses.model'; import { SubmissionAccessesModel } from './models/config-submission-accesses.model';
import { RemoteData } from '../data/remote-data'; import { RemoteData } from '../data/remote-data';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { CoreState } from '../core-state.model'; import { dataService } from '../data/base/data-service.decorator';
/** /**
* Provides methods to retrieve, from REST server, bitstream access conditions configurations applicable during the submission process. * Provides methods to retrieve, from REST server, bitstream access conditions configurations applicable during the submission process.
*/ */
@Injectable() @Injectable()
@dataService(SUBMISSION_ACCESSES_TYPE) @dataService(SUBMISSION_ACCESSES_TYPE)
export class SubmissionAccessesConfigService extends ConfigService { export class SubmissionAccessesConfigDataService extends ConfigDataService {
constructor( constructor(
protected requestService: RequestService, protected requestService: RequestService,
protected rdbService: RemoteDataBuildService, protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
protected halService: HALEndpointService, protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DefaultChangeAnalyzer<SubmissionAccessesModel>
) { ) {
super(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator, 'submissionaccessoptions'); super('submissionaccessoptions', requestService, rdbService, objectCache, halService);
} }
/**
* Returns an observable of {@link RemoteData} of an object, based on an href, with a list of
* {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object
*
* Throws an error if a configuration object cannot be retrieved.
*
* @param href The url of object we want to retrieve
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
*/
findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow): Observable<RemoteData<SubmissionAccessesModel>> { findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow): Observable<RemoteData<SubmissionAccessesModel>> {
return super.findByHref(href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow as FollowLinkConfig<ConfigObject>[]) as Observable<RemoteData<SubmissionAccessesModel>>; return super.findByHref(href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow as FollowLinkConfig<ConfigObject>[]) as Observable<RemoteData<SubmissionAccessesModel>>;
} }

View File

@@ -1,39 +1,46 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { ConfigDataService } from './config-data.service';
import { ConfigService } from './config.service';
import { RequestService } from '../data/request.service'; import { RequestService } from '../data/request.service';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { Store } from '@ngrx/store';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service';
import { ConfigObject } from './models/config.model'; import { ConfigObject } from './models/config.model';
import { dataService } from '../cache/builders/build-decorators';
import { SUBMISSION_FORMS_TYPE } from './models/config-type'; import { SUBMISSION_FORMS_TYPE } from './models/config-type';
import { SubmissionFormsModel } from './models/config-submission-forms.model'; import { SubmissionFormsModel } from './models/config-submission-forms.model';
import { RemoteData } from '../data/remote-data'; import { RemoteData } from '../data/remote-data';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { CoreState } from '../core-state.model'; import { dataService } from '../data/base/data-service.decorator';
/**
* Data service to retrieve submission form configuration objects from the REST server.
*/
@Injectable() @Injectable()
@dataService(SUBMISSION_FORMS_TYPE) @dataService(SUBMISSION_FORMS_TYPE)
export class SubmissionFormsConfigService extends ConfigService { export class SubmissionFormsConfigDataService extends ConfigDataService {
constructor( constructor(
protected requestService: RequestService, protected requestService: RequestService,
protected rdbService: RemoteDataBuildService, protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
protected halService: HALEndpointService, protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DefaultChangeAnalyzer<SubmissionFormsModel>
) { ) {
super(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator, 'submissionforms'); super('submissionforms', requestService, rdbService, objectCache, halService);
} }
/**
* Returns an observable of {@link RemoteData} of an object, based on an href, with a list of
* {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object
*
* Throws an error if a configuration object cannot be retrieved.
*
* @param href The url of object we want to retrieve
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
*/
public findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<SubmissionFormsModel>[]): Observable<RemoteData<SubmissionFormsModel>> { public findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<SubmissionFormsModel>[]): Observable<RemoteData<SubmissionFormsModel>> {
return super.findByHref(href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow as FollowLinkConfig<ConfigObject>[]) as Observable<RemoteData<SubmissionFormsModel>>; return super.findByHref(href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow as FollowLinkConfig<ConfigObject>[]) as Observable<RemoteData<SubmissionFormsModel>>;
} }

View File

@@ -1,39 +1,30 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { ConfigService } from './config.service'; import { ConfigDataService } from './config-data.service';
import { RequestService } from '../data/request.service'; import { RequestService } from '../data/request.service';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { dataService } from '../cache/builders/build-decorators';
import { SUBMISSION_UPLOADS_TYPE } from './models/config-type'; import { SUBMISSION_UPLOADS_TYPE } from './models/config-type';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { Store } from '@ngrx/store';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service';
import { ConfigObject } from './models/config.model'; import { ConfigObject } from './models/config.model';
import { SubmissionUploadsModel } from './models/config-submission-uploads.model'; import { SubmissionUploadsModel } from './models/config-submission-uploads.model';
import { RemoteData } from '../data/remote-data'; import { RemoteData } from '../data/remote-data';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { CoreState } from '../core-state.model'; import { dataService } from '../data/base/data-service.decorator';
/** /**
* Provides methods to retrieve, from REST server, bitstream access conditions configurations applicable during the submission process. * Provides methods to retrieve, from REST server, bitstream access conditions configurations applicable during the submission process.
*/ */
@Injectable() @Injectable()
@dataService(SUBMISSION_UPLOADS_TYPE) @dataService(SUBMISSION_UPLOADS_TYPE)
export class SubmissionUploadsConfigService extends ConfigService { export class SubmissionUploadsConfigDataService extends ConfigDataService {
constructor( constructor(
protected requestService: RequestService, protected requestService: RequestService,
protected rdbService: RemoteDataBuildService, protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
protected halService: HALEndpointService, protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DefaultChangeAnalyzer<SubmissionUploadsModel>
) { ) {
super(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator, 'submissionuploads'); super('submissionuploads', requestService, rdbService, objectCache, halService);
} }
findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow): Observable<RemoteData<SubmissionUploadsModel>> { findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow): Observable<RemoteData<SubmissionUploadsModel>> {

View File

@@ -36,7 +36,7 @@ import { SubmissionDefinitionsModel } from './config/models/config-submission-de
import { SubmissionFormsModel } from './config/models/config-submission-forms.model'; import { SubmissionFormsModel } from './config/models/config-submission-forms.model';
import { SubmissionSectionModel } from './config/models/config-submission-section.model'; import { SubmissionSectionModel } from './config/models/config-submission-section.model';
import { SubmissionUploadsModel } from './config/models/config-submission-uploads.model'; import { SubmissionUploadsModel } from './config/models/config-submission-uploads.model';
import { SubmissionFormsConfigService } from './config/submission-forms-config.service'; import { SubmissionFormsConfigDataService } from './config/submission-forms-config-data.service';
import { coreEffects } from './core.effects'; import { coreEffects } from './core.effects';
import { coreReducers } from './core.reducers'; import { coreReducers } from './core.reducers';
import { BitstreamFormatDataService } from './data/bitstream-format-data.service'; import { BitstreamFormatDataService } from './data/bitstream-format-data.service';
@@ -49,8 +49,8 @@ import { DSOChangeAnalyzer } from './data/dso-change-analyzer.service';
import { DSOResponseParsingService } from './data/dso-response-parsing.service'; import { DSOResponseParsingService } from './data/dso-response-parsing.service';
import { DSpaceObjectDataService } from './data/dspace-object-data.service'; import { DSpaceObjectDataService } from './data/dspace-object-data.service';
import { EndpointMapResponseParsingService } from './data/endpoint-map-response-parsing.service'; import { EndpointMapResponseParsingService } from './data/endpoint-map-response-parsing.service';
import { EntityTypeService } from './data/entity-type.service'; import { EntityTypeDataService } from './data/entity-type-data.service';
import { ExternalSourceService } from './data/external-source.service'; import { ExternalSourceDataService } from './data/external-source-data.service';
import { FacetConfigResponseParsingService } from './data/facet-config-response-parsing.service'; import { FacetConfigResponseParsingService } from './data/facet-config-response-parsing.service';
import { FacetValueResponseParsingService } from './data/facet-value-response-parsing.service'; import { FacetValueResponseParsingService } from './data/facet-value-response-parsing.service';
import { FilteredDiscoveryPageResponseParsingService } from './data/filtered-discovery-page-response-parsing.service'; import { FilteredDiscoveryPageResponseParsingService } from './data/filtered-discovery-page-response-parsing.service';
@@ -58,9 +58,9 @@ import { ItemDataService } from './data/item-data.service';
import { LookupRelationService } from './data/lookup-relation.service'; import { LookupRelationService } from './data/lookup-relation.service';
import { MyDSpaceResponseParsingService } from './data/mydspace-response-parsing.service'; import { MyDSpaceResponseParsingService } from './data/mydspace-response-parsing.service';
import { ObjectUpdatesService } from './data/object-updates/object-updates.service'; import { ObjectUpdatesService } from './data/object-updates/object-updates.service';
import { RelationshipTypeService } from './data/relationship-type.service'; import { RelationshipTypeDataService } from './data/relationship-type-data.service';
import { RelationshipService } from './data/relationship.service'; import { RelationshipDataService } from './data/relationship-data.service';
import { ResourcePolicyService } from './resource-policy/resource-policy.service'; import { ResourcePolicyDataService } from './resource-policy/resource-policy-data.service';
import { SearchResponseParsingService } from './data/search-response-parsing.service'; import { SearchResponseParsingService } from './data/search-response-parsing.service';
import { SiteDataService } from './data/site-data.service'; import { SiteDataService } from './data/site-data.service';
import { DspaceRestService } from './dspace-rest/dspace-rest.service'; import { DspaceRestService } from './dspace-rest/dspace-rest.service';
@@ -170,14 +170,16 @@ import { SubmissionAccessesModel } from './config/models/config-submission-acces
import { AccessStatusObject } from '../shared/object-list/access-status-badge/access-status.model'; import { AccessStatusObject } from '../shared/object-list/access-status-badge/access-status.model';
import { AccessStatusDataService } from './data/access-status-data.service'; import { AccessStatusDataService } from './data/access-status-data.service';
import { LinkHeadService } from './services/link-head.service'; import { LinkHeadService } from './services/link-head.service';
import { ResearcherProfileService } from './profile/researcher-profile.service'; import { ResearcherProfileDataService } from './profile/researcher-profile-data.service';
import { ProfileClaimService } from '../profile-page/profile-claim/profile-claim.service'; import { ProfileClaimService } from '../profile-page/profile-claim/profile-claim.service';
import { ResearcherProfile } from './profile/model/researcher-profile.model'; import { ResearcherProfile } from './profile/model/researcher-profile.model';
import { OrcidQueueService } from './orcid/orcid-queue.service'; import { OrcidQueueDataService } from './orcid/orcid-queue-data.service';
import { OrcidHistoryDataService } from './orcid/orcid-history-data.service'; import { OrcidHistoryDataService } from './orcid/orcid-history-data.service';
import { OrcidQueue } from './orcid/model/orcid-queue.model'; import { OrcidQueue } from './orcid/model/orcid-queue.model';
import { OrcidHistory } from './orcid/model/orcid-history.model'; import { OrcidHistory } from './orcid/model/orcid-history.model';
import { OrcidAuthService } from './orcid/orcid-auth.service'; import { OrcidAuthService } from './orcid/orcid-auth.service';
import { VocabularyDataService } from './submission/vocabularies/vocabulary.data.service';
import { VocabularyEntryDetailsDataService } from './submission/vocabularies/vocabulary-entry-details.data.service';
/** /**
* When not in production, endpoint responses can be mocked for testing purposes * When not in production, endpoint responses can be mocked for testing purposes
@@ -223,7 +225,7 @@ const PROVIDERS = [
MetadataService, MetadataService,
ObjectCacheService, ObjectCacheService,
PaginationComponentOptions, PaginationComponentOptions,
ResourcePolicyService, ResourcePolicyDataService,
RegistryService, RegistryService,
BitstreamFormatDataService, BitstreamFormatDataService,
RemoteDataBuildService, RemoteDataBuildService,
@@ -238,7 +240,7 @@ const PROVIDERS = [
AccessStatusDataService, AccessStatusDataService,
SubmissionCcLicenseDataService, SubmissionCcLicenseDataService,
SubmissionCcLicenseUrlDataService, SubmissionCcLicenseUrlDataService,
SubmissionFormsConfigService, SubmissionFormsConfigDataService,
SubmissionRestService, SubmissionRestService,
SubmissionResponseParsingService, SubmissionResponseParsingService,
SubmissionJsonPatchOperationsService, SubmissionJsonPatchOperationsService,
@@ -259,7 +261,7 @@ const PROVIDERS = [
MenuService, MenuService,
ObjectUpdatesService, ObjectUpdatesService,
SearchService, SearchService,
RelationshipService, RelationshipDataService,
MyDSpaceGuard, MyDSpaceGuard,
RoleService, RoleService,
TaskResponseParsingService, TaskResponseParsingService,
@@ -267,7 +269,7 @@ const PROVIDERS = [
PoolTaskDataService, PoolTaskDataService,
BitstreamDataService, BitstreamDataService,
DsDynamicTypeBindRelationService, DsDynamicTypeBindRelationService,
EntityTypeService, EntityTypeDataService,
ContentSourceResponseParsingService, ContentSourceResponseParsingService,
ItemTemplateDataService, ItemTemplateDataService,
SearchService, SearchService,
@@ -276,8 +278,8 @@ const PROVIDERS = [
SearchFilterService, SearchFilterService,
SearchConfigurationService, SearchConfigurationService,
SelectableListService, SelectableListService,
RelationshipTypeService, RelationshipTypeDataService,
ExternalSourceService, ExternalSourceDataService,
LookupRelationService, LookupRelationService,
VersionDataService, VersionDataService,
VersionHistoryDataService, VersionHistoryDataService,
@@ -300,14 +302,16 @@ const PROVIDERS = [
FilteredDiscoveryPageResponseParsingService, FilteredDiscoveryPageResponseParsingService,
{ provide: NativeWindowService, useFactory: NativeWindowFactory }, { provide: NativeWindowService, useFactory: NativeWindowFactory },
VocabularyService, VocabularyService,
VocabularyDataService,
VocabularyEntryDetailsDataService,
VocabularyTreeviewService, VocabularyTreeviewService,
SequenceService, SequenceService,
GroupDataService, GroupDataService,
FeedbackDataService, FeedbackDataService,
ResearcherProfileService, ResearcherProfileDataService,
ProfileClaimService, ProfileClaimService,
OrcidAuthService, OrcidAuthService,
OrcidQueueService, OrcidQueueDataService,
OrcidHistoryDataService, OrcidHistoryDataService,
]; ];

View File

@@ -76,6 +76,6 @@ describe('AccessStatusDataService', () => {
}); });
halService = new HALEndpointServiceStub(url); halService = new HALEndpointServiceStub(url);
notificationsService = new NotificationsServiceStub(); notificationsService = new NotificationsServiceStub();
service = new AccessStatusDataService(null, halService, null, notificationsService, objectCache, rdbService, requestService, null); service = new AccessStatusDataService(requestService, rdbService, objectCache, halService);
} }
}); });

View File

@@ -1,38 +1,30 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { dataService } from '../cache/builders/build-decorators';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { DataService } from './data.service';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
import { CoreState } from '../core-state.model';
import { AccessStatusObject } from 'src/app/shared/object-list/access-status-badge/access-status.model'; import { AccessStatusObject } from 'src/app/shared/object-list/access-status-badge/access-status.model';
import { ACCESS_STATUS } from 'src/app/shared/object-list/access-status-badge/access-status.resource-type'; import { ACCESS_STATUS } from 'src/app/shared/object-list/access-status-badge/access-status.resource-type';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { RemoteData } from './remote-data'; import { RemoteData } from './remote-data';
import { Item } from '../shared/item.model'; import { Item } from '../shared/item.model';
import { BaseDataService } from './base/base-data.service';
import { dataService } from './base/data-service.decorator';
/**
* Data service responsible for retrieving the access status of Items
*/
@Injectable() @Injectable()
@dataService(ACCESS_STATUS) @dataService(ACCESS_STATUS)
export class AccessStatusDataService extends DataService<AccessStatusObject> { export class AccessStatusDataService extends BaseDataService<AccessStatusObject> {
protected linkPath = 'accessStatus';
constructor( constructor(
protected comparator: DefaultChangeAnalyzer<AccessStatusObject>,
protected halService: HALEndpointService,
protected http: HttpClient,
protected notificationsService: NotificationsService,
protected objectCache: ObjectCacheService,
protected rdbService: RemoteDataBuildService,
protected requestService: RequestService, protected requestService: RequestService,
protected store: Store<CoreState>, protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
) { ) {
super(); super('accessStatus', requestService, rdbService, objectCache, halService);
} }
/** /**

View File

@@ -1,54 +1,38 @@
/* eslint-disable max-classes-per-file */ /**
import { HttpClient } from '@angular/common/http'; * The contents of this file are subject to the license and copyright
import { Store } from '@ngrx/store'; * detailed in the LICENSE and NOTICE files at the root of the source
import { compare, Operation } from 'fast-json-patch'; * tree and available online at
*
* http://www.dspace.org/license/
*/
import { RequestService } from '../request.service';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import { HALEndpointService } from '../../shared/hal-endpoint.service';
import { ObjectCacheService } from '../../cache/object-cache.service';
import { FindListOptions } from '../find-list-options.model';
import { Observable, of as observableOf } from 'rxjs'; import { Observable, of as observableOf } from 'rxjs';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { getMockRequestService } from '../../../shared/mocks/request.service.mock';
import { followLink } from '../../shared/utils/follow-link-config.model'; import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock';
import { SortDirection, SortOptions } from '../cache/models/sort-options.model'; import { followLink } from '../../../shared/utils/follow-link-config.model';
import { ObjectCacheService } from '../cache/object-cache.service';
import { DSpaceObject } from '../shared/dspace-object.model';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { Item } from '../shared/item.model';
import {
createFailedRemoteDataObject,
createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$,
} from '../../shared/remote-data.utils';
import { ChangeAnalyzer } from './change-analyzer';
import { DataService } from './data.service';
import { PatchRequest } from './request.models';
import { RequestService } from './request.service';
import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
import { RequestParam } from '../cache/models/request-param.model';
import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock';
import { TestScheduler } from 'rxjs/testing'; import { TestScheduler } from 'rxjs/testing';
import { RemoteData } from './remote-data'; import { RemoteData } from '../remote-data';
import { RequestEntryState } from './request-entry-state.model'; import { RequestEntryState } from '../request-entry-state.model';
import { CoreState } from '../core-state.model';
import { FindListOptions } from './find-list-options.model';
import { fakeAsync, tick } from '@angular/core/testing'; import { fakeAsync, tick } from '@angular/core/testing';
import { BaseDataService } from './base-data.service';
const endpoint = 'https://rest.api/core'; const endpoint = 'https://rest.api/core';
const BOOLEAN = { f: false, t: true }; const BOOLEAN = { f: false, t: true };
class TestService extends DataService<any> { class TestService extends BaseDataService<any> {
constructor( constructor(
protected requestService: RequestService, protected requestService: RequestService,
protected rdbService: RemoteDataBuildService, protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected linkPath: string,
protected halService: HALEndpointService,
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
protected notificationsService: NotificationsService, protected halService: HALEndpointService,
protected http: HttpClient,
protected comparator: ChangeAnalyzer<Item>
) { ) {
super(); super(undefined, requestService, rdbService, objectCache, halService);
} }
public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable<string> { public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable<string> {
@@ -56,24 +40,12 @@ class TestService extends DataService<any> {
} }
} }
class DummyChangeAnalyzer implements ChangeAnalyzer<Item> { describe('BaseDataService', () => {
diff(object1: Item, object2: Item): Operation[] {
return compare((object1 as any).metadata, (object2 as any).metadata);
}
}
describe('DataService', () => {
let service: TestService; let service: TestService;
let options: FindListOptions;
let requestService; let requestService;
let halService; let halService;
let rdbService; let rdbService;
let notificationsService;
let http;
let comparator;
let objectCache; let objectCache;
let store;
let selfLink; let selfLink;
let linksToFollow; let linksToFollow;
let testScheduler; let testScheduler;
@@ -83,9 +55,6 @@ describe('DataService', () => {
requestService = getMockRequestService(); requestService = getMockRequestService();
halService = new HALEndpointServiceStub('url') as any; halService = new HALEndpointServiceStub('url') as any;
rdbService = getMockRemoteDataBuildService(); rdbService = getMockRemoteDataBuildService();
notificationsService = {} as NotificationsService;
http = {} as HttpClient;
comparator = new DummyChangeAnalyzer() as any;
objectCache = { objectCache = {
addPatch: () => { addPatch: () => {
@@ -98,7 +67,6 @@ describe('DataService', () => {
/* empty */ /* empty */
} }
} as any; } as any;
store = {} as Store<CoreState>;
selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7';
linksToFollow = [ linksToFollow = [
followLink('a'), followLink('a'),
@@ -126,17 +94,11 @@ describe('DataService', () => {
ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError), ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError),
}; };
return new TestService( return new TestService(
requestService, requestService,
rdbService, rdbService,
store,
endpoint,
halService,
objectCache, objectCache,
notificationsService, halService,
http,
comparator,
); );
} }
@@ -144,259 +106,6 @@ describe('DataService', () => {
service = initTestService(); service = initTestService();
}); });
describe('getFindAllHref', () => {
it('should return an observable with the endpoint', () => {
options = {};
(service as any).getFindAllHref(options).subscribe((value) => {
expect(value).toBe(endpoint);
}
);
});
it('should include page in href if currentPage provided in options', () => {
options = { currentPage: 2 };
const expected = `${endpoint}?page=${options.currentPage - 1}`;
(service as any).getFindAllHref(options).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include size in href if elementsPerPage provided in options', () => {
options = { elementsPerPage: 5 };
const expected = `${endpoint}?size=${options.elementsPerPage}`;
(service as any).getFindAllHref(options).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include sort href if SortOptions provided in options', () => {
const sortOptions = new SortOptions('field1', SortDirection.ASC);
options = { sort: sortOptions };
const expected = `${endpoint}?sort=${sortOptions.field},${sortOptions.direction}`;
(service as any).getFindAllHref(options).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include startsWith in href if startsWith provided in options', () => {
options = { startsWith: 'ab' };
const expected = `${endpoint}?startsWith=${options.startsWith}`;
(service as any).getFindAllHref(options).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include all provided options in href', () => {
const sortOptions = new SortOptions('field1', SortDirection.DESC);
options = {
currentPage: 6,
elementsPerPage: 10,
sort: sortOptions,
startsWith: 'ab',
};
const expected = `${endpoint}?page=${options.currentPage - 1}&size=${options.elementsPerPage}` +
`&sort=${sortOptions.field},${sortOptions.direction}&startsWith=${options.startsWith}`;
(service as any).getFindAllHref(options).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include all searchParams in href if any provided in options', () => {
options = {
searchParams: [
new RequestParam('param1', 'test'),
new RequestParam('param2', 'test2'),
]
};
const expected = `${endpoint}?param1=test&param2=test2`;
(service as any).getFindAllHref(options).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include linkPath in href if any provided', () => {
const expected = `${endpoint}/test/entries`;
(service as any).getFindAllHref({}, 'test/entries').subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include single linksToFollow as embed', () => {
const expected = `${endpoint}?embed=bundles`;
(service as any).getFindAllHref({}, null, followLink('bundles')).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include single linksToFollow as embed and its size', () => {
const expected = `${endpoint}?embed.size=bundles=5&embed=bundles`;
const config: FindListOptions = Object.assign(new FindListOptions(), {
elementsPerPage: 5
});
(service as any).getFindAllHref({}, null, followLink('bundles', { findListOptions: config })).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include multiple linksToFollow as embed', () => {
const expected = `${endpoint}?embed=bundles&embed=owningCollection&embed=templateItemOf`;
(service as any).getFindAllHref({}, null, followLink('bundles'), followLink('owningCollection'), followLink('templateItemOf')).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include multiple linksToFollow as embed and its sizes if given', () => {
const expected = `${endpoint}?embed=bundles&embed.size=owningCollection=2&embed=owningCollection&embed=templateItemOf`;
const config: FindListOptions = Object.assign(new FindListOptions(), {
elementsPerPage: 2
});
(service as any).getFindAllHref({}, null, followLink('bundles'), followLink('owningCollection', { findListOptions: config }), followLink('templateItemOf')).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should not include linksToFollow with shouldEmbed = false', () => {
const expected = `${endpoint}?embed=templateItemOf`;
(service as any).getFindAllHref(
{},
null,
followLink('bundles', { shouldEmbed: false }),
followLink('owningCollection', { shouldEmbed: false }),
followLink('templateItemOf')
).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include nested linksToFollow 3lvl', () => {
const expected = `${endpoint}?embed=owningCollection/itemtemplate/relationships`;
(service as any).getFindAllHref({}, null, followLink('owningCollection', {}, followLink('itemtemplate', {}, followLink('relationships')))).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include nested linksToFollow 2lvl and nested embed\'s size', () => {
const expected = `${endpoint}?embed.size=owningCollection/itemtemplate=4&embed=owningCollection/itemtemplate`;
const config: FindListOptions = Object.assign(new FindListOptions(), {
elementsPerPage: 4
});
(service as any).getFindAllHref({}, null, followLink('owningCollection', {}, followLink('itemtemplate', { findListOptions: config }))).subscribe((value) => {
expect(value).toBe(expected);
});
});
});
describe('getIDHref', () => {
const endpointMock = 'https://dspace7-internal.atmire.com/server/api/core/items';
const resourceIdMock = '003c99b4-d4fe-44b0-a945-e12182a7ca89';
it('should return endpoint', () => {
const result = (service as any).getIDHref(endpointMock, resourceIdMock);
expect(result).toEqual(endpointMock + '/' + resourceIdMock);
});
it('should include single linksToFollow as embed', () => {
const expected = `${endpointMock}/${resourceIdMock}?embed=bundles`;
const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('bundles'));
expect(result).toEqual(expected);
});
it('should include multiple linksToFollow as embed', () => {
const expected = `${endpointMock}/${resourceIdMock}?embed=bundles&embed=owningCollection&embed=templateItemOf`;
const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('bundles'), followLink('owningCollection'), followLink('templateItemOf'));
expect(result).toEqual(expected);
});
it('should not include linksToFollow with shouldEmbed = false', () => {
const expected = `${endpointMock}/${resourceIdMock}?embed=templateItemOf`;
const result = (service as any).getIDHref(
endpointMock,
resourceIdMock,
followLink('bundles', { shouldEmbed: false }),
followLink('owningCollection', { shouldEmbed: false }),
followLink('templateItemOf')
);
expect(result).toEqual(expected);
});
it('should include nested linksToFollow 3lvl', () => {
const expected = `${endpointMock}/${resourceIdMock}?embed=owningCollection/itemtemplate/relationships`;
const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('owningCollection', {}, followLink('itemtemplate', {}, followLink('relationships'))));
expect(result).toEqual(expected);
});
});
describe('patch', () => {
const dso = {
uuid: 'dso-uuid'
};
const operations = [
Object.assign({
op: 'move',
from: '/1',
path: '/5'
}) as Operation
];
beforeEach(() => {
service.patch(dso, operations);
});
it('should send a PatchRequest', () => {
expect(requestService.send).toHaveBeenCalledWith(jasmine.any(PatchRequest));
});
});
describe('update', () => {
let operations;
let dso;
let dso2;
const name1 = 'random string';
const name2 = 'another random string';
beforeEach(() => {
operations = [{ op: 'replace', path: '/0/value', value: name2 } as Operation];
dso = Object.assign(new DSpaceObject(), {
_links: { self: { href: selfLink } },
metadata: [{ key: 'dc.title', value: name1 }]
});
dso2 = Object.assign(new DSpaceObject(), {
_links: { self: { href: selfLink } },
metadata: [{ key: 'dc.title', value: name2 }]
});
spyOn(service, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(dso));
spyOn(objectCache, 'addPatch');
});
it('should call addPatch on the object cache with the right parameters when there are differences', () => {
service.update(dso2).subscribe();
expect(objectCache.addPatch).toHaveBeenCalledWith(selfLink, operations);
});
it('should not call addPatch on the object cache with the right parameters when there are no differences', () => {
service.update(dso).subscribe();
expect(objectCache.addPatch).not.toHaveBeenCalled();
});
});
describe(`reRequestStaleRemoteData`, () => { describe(`reRequestStaleRemoteData`, () => {
let callback: jasmine.Spy<jasmine.Func>; let callback: jasmine.Spy<jasmine.Func>;
@@ -661,7 +370,7 @@ describe('DataService', () => {
}); });
describe(`findAllByHref`, () => { describe(`findListByHref`, () => {
let findListOptions; let findListOptions;
beforeEach(() => { beforeEach(() => {
findListOptions = { currentPage: 5 }; findListOptions = { currentPage: 5 };
@@ -674,7 +383,7 @@ describe('DataService', () => {
spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success }));
spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success }));
service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow); service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow);
expect(service.buildHrefFromFindOptions).toHaveBeenCalledWith(selfLink, findListOptions, [], ...linksToFollow); expect(service.buildHrefFromFindOptions).toHaveBeenCalledWith(selfLink, findListOptions, [], ...linksToFollow);
}); });
}); });
@@ -685,11 +394,11 @@ describe('DataService', () => {
spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success }));
spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success }));
service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow); service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow);
expect((service as any).createAndSendGetRequest).toHaveBeenCalledWith(jasmine.anything(), true); expect((service as any).createAndSendGetRequest).toHaveBeenCalledWith(jasmine.anything(), true);
expectObservable(rdbService.buildList.calls.argsFor(0)[0]).toBe('(a|)', { a: 'bingo!' }); expectObservable(rdbService.buildList.calls.argsFor(0)[0]).toBe('(a|)', { a: 'bingo!' });
service.findAllByHref(selfLink, findListOptions, false, true, ...linksToFollow); service.findListByHref(selfLink, findListOptions, false, true, ...linksToFollow);
expect((service as any).createAndSendGetRequest).toHaveBeenCalledWith(jasmine.anything(), false); expect((service as any).createAndSendGetRequest).toHaveBeenCalledWith(jasmine.anything(), false);
expectObservable(rdbService.buildList.calls.argsFor(1)[0]).toBe('(a|)', { a: 'bingo!' }); expectObservable(rdbService.buildList.calls.argsFor(1)[0]).toBe('(a|)', { a: 'bingo!' });
}); });
@@ -701,29 +410,29 @@ describe('DataService', () => {
spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success })); spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.Success }));
spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success })); spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.Success }));
service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow); service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow);
expect(rdbService.buildList).toHaveBeenCalledWith(jasmine.anything() as any, ...linksToFollow); expect(rdbService.buildList).toHaveBeenCalledWith(jasmine.anything() as any, ...linksToFollow);
expectObservable(rdbService.buildList.calls.argsFor(0)[0]).toBe('(a|)', { a: 'bingo!' }); expectObservable(rdbService.buildList.calls.argsFor(0)[0]).toBe('(a|)', { a: 'bingo!' });
}); });
}); });
it(`should call reRequestStaleRemoteData with reRequestOnStale and the exact same findAllByHref call as a callback`, () => { it(`should call reRequestStaleRemoteData with reRequestOnStale and the exact same findListByHref call as a callback`, () => {
testScheduler.run(({ cold, expectObservable }) => { testScheduler.run(({ cold, expectObservable }) => {
spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!'); spyOn(service, 'buildHrefFromFindOptions').and.returnValue('bingo!');
spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.SuccessStale })); spyOn(rdbService, 'buildList').and.returnValue(cold('a', { a: remoteDataMocks.SuccessStale }));
spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.SuccessStale })); spyOn(service as any, 'reRequestStaleRemoteData').and.returnValue(() => cold('a', { a: remoteDataMocks.SuccessStale }));
service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow); service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow);
expect((service as any).reRequestStaleRemoteData.calls.argsFor(0)[0]).toBeTrue(); expect((service as any).reRequestStaleRemoteData.calls.argsFor(0)[0]).toBeTrue();
spyOn(service, 'findAllByHref').and.returnValue(cold('a', { a: remoteDataMocks.SuccessStale })); spyOn(service, 'findListByHref').and.returnValue(cold('a', { a: remoteDataMocks.SuccessStale }));
// prove that the spy we just added hasn't been called yet // prove that the spy we just added hasn't been called yet
expect(service.findAllByHref).not.toHaveBeenCalled(); expect(service.findListByHref).not.toHaveBeenCalled();
// call the callback passed to reRequestStaleRemoteData // call the callback passed to reRequestStaleRemoteData
(service as any).reRequestStaleRemoteData.calls.argsFor(0)[1](); (service as any).reRequestStaleRemoteData.calls.argsFor(0)[1]();
// verify that findAllByHref _has_ been called now, with the same params as the original call // verify that findListByHref _has_ been called now, with the same params as the original call
expect(service.findAllByHref).toHaveBeenCalledWith(jasmine.anything(), findListOptions, true, true, ...linksToFollow); expect(service.findListByHref).toHaveBeenCalledWith(jasmine.anything(), findListOptions, true, true, ...linksToFollow);
// ... except for selflink, which will have been turned in to an observable. // ... except for selflink, which will have been turned in to an observable.
expectObservable((service.findAllByHref as jasmine.Spy).calls.argsFor(0)[0]).toBe('(a|)', { a: selfLink }); expectObservable((service.findListByHref as jasmine.Spy).calls.argsFor(0)[0]).toBe('(a|)', { a: selfLink });
}); });
}); });
@@ -737,7 +446,7 @@ describe('DataService', () => {
a: 'bingo!', a: 'bingo!',
}; };
expectObservable(service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); expectObservable(service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values);
}); });
}); });
@@ -765,7 +474,7 @@ describe('DataService', () => {
e: remoteDataMocks.SuccessStale, e: remoteDataMocks.SuccessStale,
}; };
expectObservable(service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); expectObservable(service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values);
}); });
}); });
@@ -786,7 +495,7 @@ describe('DataService', () => {
e: remoteDataMocks.SuccessStale, e: remoteDataMocks.SuccessStale,
}; };
expectObservable(service.findAllByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values); expectObservable(service.findListByHref(selfLink, findListOptions, true, true, ...linksToFollow)).toBe(expected, values);
}); });
}); });
@@ -816,7 +525,7 @@ describe('DataService', () => {
e: remoteDataMocks.SuccessStale, e: remoteDataMocks.SuccessStale,
}; };
expectObservable(service.findAllByHref(selfLink, findListOptions, false, true, ...linksToFollow)).toBe(expected, values); expectObservable(service.findListByHref(selfLink, findListOptions, false, true, ...linksToFollow)).toBe(expected, values);
}); });
}); });
@@ -837,7 +546,7 @@ describe('DataService', () => {
e: remoteDataMocks.SuccessStale, e: remoteDataMocks.SuccessStale,
}; };
expectObservable(service.findAllByHref(selfLink, findListOptions, false, true, ...linksToFollow)).toBe(expected, values); expectObservable(service.findListByHref(selfLink, findListOptions, false, true, ...linksToFollow)).toBe(expected, values);
}); });
}); });
@@ -915,97 +624,4 @@ describe('DataService', () => {
}); });
}); });
}); });
describe('delete', () => {
let MOCK_SUCCEEDED_RD;
let MOCK_FAILED_RD;
let invalidateByHrefSpy: jasmine.Spy;
let buildFromRequestUUIDSpy: jasmine.Spy;
let getIDHrefObsSpy: jasmine.Spy;
let deleteByHrefSpy: jasmine.Spy;
beforeEach(() => {
invalidateByHrefSpy = spyOn(service, 'invalidateByHref').and.returnValue(observableOf(true));
buildFromRequestUUIDSpy = spyOn(rdbService, 'buildFromRequestUUID').and.callThrough();
getIDHrefObsSpy = spyOn(service, 'getIDHrefObs').and.callThrough();
deleteByHrefSpy = spyOn(service, 'deleteByHref').and.callThrough();
MOCK_SUCCEEDED_RD = createSuccessfulRemoteDataObject({});
MOCK_FAILED_RD = createFailedRemoteDataObject('something went wrong');
});
it('should retrieve href by ID and call deleteByHref', () => {
getIDHrefObsSpy.and.returnValue(observableOf('some-href'));
buildFromRequestUUIDSpy.and.returnValue(createSuccessfulRemoteDataObject$({}));
service.delete('some-id', ['a', 'b', 'c']).subscribe(rd => {
expect(getIDHrefObsSpy).toHaveBeenCalledWith('some-id');
expect(deleteByHrefSpy).toHaveBeenCalledWith('some-href', ['a', 'b', 'c']);
});
});
describe('deleteByHref', () => {
it('should call invalidateByHref if the DELETE request succeeds', (done) => {
buildFromRequestUUIDSpy.and.returnValue(observableOf(MOCK_SUCCEEDED_RD));
service.deleteByHref('some-href').subscribe(rd => {
expect(rd).toBe(MOCK_SUCCEEDED_RD);
expect(invalidateByHrefSpy).toHaveBeenCalled();
done();
});
});
it('should call invalidateByHref even if not subscribing to returned Observable', fakeAsync(() => {
buildFromRequestUUIDSpy.and.returnValue(observableOf(MOCK_SUCCEEDED_RD));
service.deleteByHref('some-href');
tick();
expect(invalidateByHrefSpy).toHaveBeenCalled();
}));
it('should not call invalidateByHref if the DELETE request fails', (done) => {
buildFromRequestUUIDSpy.and.returnValue(observableOf(MOCK_FAILED_RD));
service.deleteByHref('some-href').subscribe(rd => {
expect(rd).toBe(MOCK_FAILED_RD);
expect(invalidateByHrefSpy).not.toHaveBeenCalled();
done();
});
});
it('should wait for invalidateByHref before emitting', () => {
testScheduler.run(({ cold, expectObservable }) => {
buildFromRequestUUIDSpy.and.returnValue(
cold('(r|)', { r: MOCK_SUCCEEDED_RD}) // RD emits right away
);
invalidateByHrefSpy.and.returnValue(
cold('----(t|)', BOOLEAN) // but we pretend that setting requests to stale takes longer
);
const done$ = service.deleteByHref('some-href');
expectObservable(done$).toBe(
'----(r|)', { r: MOCK_SUCCEEDED_RD} // ...and expect the returned Observable to wait until that's done
);
});
});
it('should wait for the DELETE request to resolve before emitting', () => {
testScheduler.run(({ cold, expectObservable }) => {
buildFromRequestUUIDSpy.and.returnValue(
cold('----(r|)', { r: MOCK_SUCCEEDED_RD}) // the request takes a while
);
invalidateByHrefSpy.and.returnValue(
cold('(t|)', BOOLEAN) // but we pretend that setting to stale happens sooner
); // e.g.: maybe already stale before this call?
const done$ = service.deleteByHref('some-href');
expectObservable(done$).toBe(
'----(r|)', { r: MOCK_SUCCEEDED_RD} // ...and expect the returned Observable to wait for the request
);
});
});
});
});
}); });

View File

@@ -0,0 +1,374 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { AsyncSubject, from as observableFrom, Observable, of as observableOf } from 'rxjs';
import { map, mergeMap, skipWhile, switchMap, take, tap, toArray } from 'rxjs/operators';
import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util';
import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import { RequestParam } from '../../cache/models/request-param.model';
import { HALEndpointService } from '../../shared/hal-endpoint.service';
import { URLCombiner } from '../../url-combiner/url-combiner';
import { RemoteData } from '../remote-data';
import { GetRequest } from '../request.models';
import { RequestService } from '../request.service';
import { CacheableObject } from '../../cache/cacheable-object.model';
import { FindListOptions } from '../find-list-options.model';
import { PaginatedList } from '../paginated-list.model';
import { ObjectCacheEntry } from '../../cache/object-cache.reducer';
import { ObjectCacheService } from '../../cache/object-cache.service';
import { HALDataService } from './hal-data-service.interface';
/**
* Common functionality for data services.
* Specific functionality that not all services would need
* is implemented in "DataService feature" classes (e.g. {@link CreateData}
*
* All DataService (or DataService feature) classes must
* - extend this class (or {@link IdentifiableDataService})
* - implement any DataService features it requires in order to forward calls to it
*
* ```
* export class SomeDataService extends BaseDataService<Something> implements CreateData<Something>, SearchData<Something> {
* private createData: CreateData<Something>;
* private searchData: SearchDataData<Something>;
*
* create(...) {
* return this.createData.create(...);
* }
*
* searchBy(...) {
* return this.searchData.searchBy(...);
* }
* }
* ```
*/
export class BaseDataService<T extends CacheableObject> implements HALDataService<T> {
constructor(
protected linkPath: string,
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected responseMsToLive?: number,
) {
}
/**
* Allows subclasses to reset the response cache time.
*/
/**
* Get the endpoint for browsing
* @param options The [[FindListOptions]] object
* @param linkPath The link path for the object
* @returns {Observable<string>}
*/
getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable<string> {
return this.getEndpoint();
}
/**
* Get the base endpoint for all requests
*/
protected getEndpoint(): Observable<string> {
return this.halService.getEndpoint(this.linkPath);
}
/**
* Turn an options object into a query string and combine it with the given HREF
*
* @param href The HREF to which the query string should be appended
* @param options The [[FindListOptions]] object
* @param extraArgs Array with additional params to combine with query string
* @return {Observable<string>}
* Return an observable that emits created HREF
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
public buildHrefFromFindOptions(href: string, options: FindListOptions, extraArgs: string[] = [], ...linksToFollow: FollowLinkConfig<T>[]): string {
let args = [...extraArgs];
if (hasValue(options.currentPage) && typeof options.currentPage === 'number') {
/* TODO: this is a temporary fix for the pagination start index (0 or 1) discrepancy between the rest and the frontend respectively */
args = this.addHrefArg(href, args, `page=${options.currentPage - 1}`);
}
if (hasValue(options.elementsPerPage)) {
args = this.addHrefArg(href, args, `size=${options.elementsPerPage}`);
}
if (hasValue(options.sort)) {
args = this.addHrefArg(href, args, `sort=${options.sort.field},${options.sort.direction}`);
}
if (hasValue(options.startsWith)) {
args = this.addHrefArg(href, args, `startsWith=${options.startsWith}`);
}
if (hasValue(options.searchParams)) {
options.searchParams.forEach((param: RequestParam) => {
args = this.addHrefArg(href, args, `${param.fieldName}=${param.fieldValue}`);
});
}
args = this.addEmbedParams(href, args, ...linksToFollow);
if (isNotEmpty(args)) {
return new URLCombiner(href, `?${args.join('&')}`).toString();
} else {
return href;
}
}
/**
* Turn an array of RequestParam into a query string and combine it with the given HREF
*
* @param href The HREF to which the query string should be appended
* @param params Array with additional params to combine with query string
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*
* @return {Observable<string>}
* Return an observable that emits created HREF
*/
buildHrefWithParams(href: string, params: RequestParam[], ...linksToFollow: FollowLinkConfig<T>[]): string {
let args = [];
if (hasValue(params)) {
params.forEach((param: RequestParam) => {
args = this.addHrefArg(href, args, `${param.fieldName}=${param.fieldValue}`);
});
}
args = this.addEmbedParams(href, args, ...linksToFollow);
if (isNotEmpty(args)) {
return new URLCombiner(href, `?${args.join('&')}`).toString();
} else {
return href;
}
}
/**
* Adds the embed options to the link for the request
* @param href The href the params are to be added to
* @param args params for the query string
* @param linksToFollow links we want to embed in query string if shouldEmbed is true
*/
protected addEmbedParams(href: string, args: string[], ...linksToFollow: FollowLinkConfig<T>[]) {
linksToFollow.forEach((linkToFollow: FollowLinkConfig<T>) => {
if (hasValue(linkToFollow) && linkToFollow.shouldEmbed) {
const embedString = 'embed=' + String(linkToFollow.name);
// Add the embeds size if given in the FollowLinkConfig.FindListOptions
if (hasValue(linkToFollow.findListOptions) && hasValue(linkToFollow.findListOptions.elementsPerPage)) {
args = this.addHrefArg(href, args,
'embed.size=' + String(linkToFollow.name) + '=' + linkToFollow.findListOptions.elementsPerPage);
}
// Adds the nested embeds and their size if given
if (isNotEmpty(linkToFollow.linksToFollow)) {
args = this.addNestedEmbeds(embedString, href, args, ...linkToFollow.linksToFollow);
} else {
args = this.addHrefArg(href, args, embedString);
}
}
});
return args;
}
/**
* Add a new argument to the list of arguments, only if it doesn't already exist in the given href,
* or the current list of arguments
*
* @param href The href the arguments are to be added to
* @param currentArgs The current list of arguments
* @param newArg The new argument to add
* @return The next list of arguments, with newArg included if it wasn't already.
* Note this function will not modify any of the input params.
*/
protected addHrefArg(href: string, currentArgs: string[], newArg: string): string[] {
if (href.includes(newArg) || currentArgs.includes(newArg)) {
return [...currentArgs];
} else {
return [...currentArgs, newArg];
}
}
/**
* Add the nested followLinks to the embed param, separated by a /, and their sizes, recursively
* @param embedString embedString so far (recursive)
* @param href The href the params are to be added to
* @param args params for the query string
* @param linksToFollow links we want to embed in query string if shouldEmbed is true
*/
protected addNestedEmbeds(embedString: string, href: string, args: string[], ...linksToFollow: FollowLinkConfig<T>[]): string[] {
let nestEmbed = embedString;
linksToFollow.forEach((linkToFollow: FollowLinkConfig<T>) => {
if (hasValue(linkToFollow) && linkToFollow.shouldEmbed) {
nestEmbed = nestEmbed + '/' + String(linkToFollow.name);
// Add the nested embeds size if given in the FollowLinkConfig.FindListOptions
if (hasValue(linkToFollow.findListOptions) && hasValue(linkToFollow.findListOptions.elementsPerPage)) {
const nestedEmbedSize = 'embed.size=' + nestEmbed.split('=')[1] + '=' + linkToFollow.findListOptions.elementsPerPage;
args = this.addHrefArg(href, args, nestedEmbedSize);
}
if (hasValue(linkToFollow.linksToFollow) && isNotEmpty(linkToFollow.linksToFollow)) {
args = this.addNestedEmbeds(nestEmbed, href, args, ...linkToFollow.linksToFollow);
} else {
args = this.addHrefArg(href, args, nestEmbed);
}
}
});
return args;
}
/**
* An operator that will call the given function if the incoming RemoteData is stale and
* shouldReRequest is true
*
* @param shouldReRequest Whether or not to call the re-request function if the RemoteData is stale
* @param requestFn The function to call if the RemoteData is stale and shouldReRequest is
* true
*/
protected reRequestStaleRemoteData<O>(shouldReRequest: boolean, requestFn: () => Observable<RemoteData<O>>) {
return (source: Observable<RemoteData<O>>): Observable<RemoteData<O>> => {
if (shouldReRequest === true) {
return source.pipe(
tap((remoteData: RemoteData<O>) => {
if (hasValue(remoteData) && remoteData.isStale) {
requestFn();
}
})
);
} else {
return source;
}
};
}
/**
* Returns an observable of {@link RemoteData} of an object, based on an href, with a list of
* {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object
* @param href$ The url of object we want to retrieve. Can be a string or
* an Observable<string>
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
*/
findByHref(href$: string | Observable<string>, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<T>> {
if (typeof href$ === 'string') {
href$ = observableOf(href$);
}
const requestHref$ = href$.pipe(
isNotEmptyOperator(),
take(1),
map((href: string) => this.buildHrefFromFindOptions(href, {}, [], ...linksToFollow)),
);
this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable);
return this.rdbService.buildSingle<T>(requestHref$, ...linksToFollow).pipe(
// This skip ensures that if a stale object is present in the cache when you do a
// call it isn't immediately returned, but we wait until the remote data for the new request
// is created. If useCachedVersionIfAvailable is false it also ensures you don't get a
// cached completed object
skipWhile((rd: RemoteData<T>) => useCachedVersionIfAvailable ? rd.isStale : rd.hasCompleted),
this.reRequestStaleRemoteData(reRequestOnStale, () =>
this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)),
);
}
/**
* Returns an Observable of a {@link RemoteData} of a {@link PaginatedList} of objects, based on an href,
* with a list of {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object
*
* @param href$ The url of list we want to retrieve. Can be a string or an Observable<string>
* @param options
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's no valid cached version.
* @param reRequestOnStale Whether or not the request should automatically be re-requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
findListByHref(href$: string | Observable<string>, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<PaginatedList<T>>> {
if (typeof href$ === 'string') {
href$ = observableOf(href$);
}
const requestHref$ = href$.pipe(
isNotEmptyOperator(),
take(1),
map((href: string) => this.buildHrefFromFindOptions(href, options, [], ...linksToFollow)),
);
this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable);
return this.rdbService.buildList<T>(requestHref$, ...linksToFollow).pipe(
// This skip ensures that if a stale object is present in the cache when you do a
// call it isn't immediately returned, but we wait until the remote data for the new request
// is created. If useCachedVersionIfAvailable is false it also ensures you don't get a
// cached completed object
skipWhile((rd: RemoteData<PaginatedList<T>>) => useCachedVersionIfAvailable ? rd.isStale : rd.hasCompleted),
this.reRequestStaleRemoteData(reRequestOnStale, () =>
this.findListByHref(href$, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow)),
);
}
/**
* Create a GET request for the given href, and send it.
*
* @param href$ The url of object we want to retrieve. Can be a string or
* an Observable<string>
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
*/
protected createAndSendGetRequest(href$: string | Observable<string>, useCachedVersionIfAvailable = true): void {
if (isNotEmpty(href$)) {
if (typeof href$ === 'string') {
href$ = observableOf(href$);
}
href$.pipe(
isNotEmptyOperator(),
take(1)
).subscribe((href: string) => {
const requestId = this.requestService.generateRequestId();
const request = new GetRequest(requestId, href);
if (hasValue(this.responseMsToLive)) {
request.responseMsToLive = this.responseMsToLive;
}
this.requestService.send(request, useCachedVersionIfAvailable);
});
}
}
/**
* Return the links to traverse from the root of the api to the
* endpoint this DataService represents
*
* e.g. if the api root links to 'foo', and the endpoint at 'foo'
* links to 'bar' the linkPath for the BarDataService would be
* 'foo/bar'
*/
getLinkPath(): string {
return this.linkPath;
}
/**
* Invalidate a cached object by its href
* @param href the href to invalidate
*/
public invalidateByHref(href: string): Observable<boolean> {
const done$ = new AsyncSubject<boolean>();
this.objectCache.getByHref(href).pipe(
take(1),
switchMap((oce: ObjectCacheEntry) => observableFrom(oce.requestUUIDs).pipe(
mergeMap((requestUUID: string) => this.requestService.setStaleByUUID(requestUUID)),
toArray(),
)),
).subscribe(() => {
done$.next(true);
done$.complete();
});
return done$;
}
}

View File

@@ -0,0 +1,225 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { RequestService } from '../request.service';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../../cache/object-cache.service';
import { HALEndpointService } from '../../shared/hal-endpoint.service';
import { FindListOptions } from '../find-list-options.model';
import { Observable, of as observableOf } from 'rxjs';
import { CreateData, CreateDataImpl } from './create-data';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { getMockRequestService } from '../../../shared/mocks/request.service.mock';
import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub';
import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock';
import { RemoteData } from '../remote-data';
import { RequestEntryState } from '../request-entry-state.model';
import { createFailedRemoteDataObject, createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils';
import { RequestParam } from '../../cache/models/request-param.model';
import { RestRequestMethod } from '../rest-request-method';
import { DSpaceObject } from '../../shared/dspace-object.model';
/**
* Tests whether calls to `CreateData` methods are correctly patched through in a concrete data service that implements it
*/
export function testCreateDataImplementation(serviceFactory: () => CreateData<any>) {
let service;
describe('CreateData implementation', () => {
const OBJ = Object.assign(new DSpaceObject(), {
uuid: '08eec68f-45e4-47a3-80c5-f0beb5627079',
});
const PARAMS = [
new RequestParam('abc', 123), new RequestParam('def', 456),
];
beforeAll(() => {
service = serviceFactory();
(service as any).createData = jasmine.createSpyObj('createData', {
create: 'TEST create',
});
});
it('should handle calls to create', () => {
const out: any = service.create(OBJ, ...PARAMS);
expect((service as any).createData.create).toHaveBeenCalledWith(OBJ, ...PARAMS);
expect(out).toBe('TEST create');
});
});
}
const endpoint = 'https://rest.api/core';
class TestService extends CreateDataImpl<any> {
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
) {
super('test', requestService, rdbService, objectCache, halService, notificationsService, undefined);
}
public getEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable<string> {
return observableOf(endpoint);
}
}
describe('CreateDataImpl', () => {
let service: TestService;
let requestService;
let halService;
let rdbService;
let objectCache;
let notificationsService;
let remoteDataMocks;
let obj;
let MOCK_SUCCEEDED_RD;
let MOCK_FAILED_RD;
let buildFromRequestUUIDSpy: jasmine.Spy;
let createOnEndpointSpy: jasmine.Spy;
function initTestService(): TestService {
requestService = getMockRequestService();
halService = new HALEndpointServiceStub('url') as any;
rdbService = getMockRemoteDataBuildService();
objectCache = {
addPatch: () => {
/* empty */
},
getObjectBySelfLink: () => {
/* empty */
},
getByHref: () => {
/* empty */
},
} as any;
notificationsService = jasmine.createSpyObj('notificationsService', {
error: undefined,
});
obj = {
uuid: '1698f1d3-be98-4c51-9fd8-6bfedcbd59b7',
};
const timeStamp = new Date().getTime();
const msToLive = 15 * 60 * 1000;
const payload = { foo: 'bar' };
const statusCodeSuccess = 200;
const statusCodeError = 404;
const errorMessage = 'not found';
remoteDataMocks = {
RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined),
ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined),
Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess),
SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess),
Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError),
ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError),
};
return new TestService(
requestService,
rdbService,
objectCache,
halService,
notificationsService,
);
}
beforeEach(() => {
service = initTestService();
buildFromRequestUUIDSpy = spyOn(rdbService, 'buildFromRequestUUID').and.callThrough();
createOnEndpointSpy = spyOn(service, 'createOnEndpoint').and.callThrough();
MOCK_SUCCEEDED_RD = createSuccessfulRemoteDataObject({});
MOCK_FAILED_RD = createFailedRemoteDataObject('something went wrong');
});
describe('create', () => {
it('should POST the object to the root endpoint with the given parameters and return the remote data', (done) => {
const params = [
new RequestParam('abc', 123), new RequestParam('def', 456)
];
buildFromRequestUUIDSpy.and.returnValue(observableOf(remoteDataMocks.Success));
service.create(obj, ...params).subscribe(out => {
expect(createOnEndpointSpy).toHaveBeenCalledWith(obj, jasmine.anything());
expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({
method: RestRequestMethod.POST,
uuid: requestService.generateRequestId(),
href: 'https://rest.api/core?abc=123&def=456',
body: JSON.stringify(obj),
}));
expect(buildFromRequestUUIDSpy).toHaveBeenCalledWith(requestService.generateRequestId());
expect(out).toEqual(remoteDataMocks.Success);
done();
});
});
});
describe('createOnEndpoint', () => {
beforeEach(() => {
buildFromRequestUUIDSpy.and.returnValue(observableOf(remoteDataMocks.Success));
});
it('should send a POST request with the object as JSON', (done) => {
service.createOnEndpoint(obj, observableOf('https://rest.api/core/custom?search')).subscribe(out => {
expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({
method: RestRequestMethod.POST,
body: JSON.stringify(obj),
}));
done();
});
});
it('should send the POST request to the given endpoint', (done) => {
service.createOnEndpoint(obj, observableOf('https://rest.api/core/custom?search')).subscribe(out => {
expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({
method: RestRequestMethod.POST,
href: 'https://rest.api/core/custom?search',
}));
done();
});
});
it('should return the remote data for the sent request', (done) => {
service.createOnEndpoint(obj, observableOf('https://rest.api/core/custom?search')).subscribe(out => {
expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({
method: RestRequestMethod.POST,
uuid: requestService.generateRequestId(),
}));
expect(buildFromRequestUUIDSpy).toHaveBeenCalledWith(requestService.generateRequestId());
expect(notificationsService.error).not.toHaveBeenCalled();
expect(out).toEqual(remoteDataMocks.Success);
done();
});
});
it('should show an error notification if the request fails', (done) => {
buildFromRequestUUIDSpy.and.returnValue(observableOf(remoteDataMocks.Error));
service.createOnEndpoint(obj, observableOf('https://rest.api/core/custom?search')).subscribe(out => {
expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({
method: RestRequestMethod.POST,
uuid: requestService.generateRequestId(),
}));
expect(buildFromRequestUUIDSpy).toHaveBeenCalledWith(requestService.generateRequestId());
expect(notificationsService.error).toHaveBeenCalled();
expect(out).toEqual(remoteDataMocks.Error);
done();
});
});
});
});

View File

@@ -0,0 +1,107 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { CacheableObject } from '../../cache/cacheable-object.model';
import { BaseDataService } from './base-data.service';
import { RequestParam } from '../../cache/models/request-param.model';
import { Observable } from 'rxjs';
import { RemoteData } from '../remote-data';
import { hasValue, isNotEmptyOperator } from '../../../shared/empty.util';
import { distinctUntilChanged, map, take, takeWhile } from 'rxjs/operators';
import { DSpaceSerializer } from '../../dspace-rest/dspace.serializer';
import { getClassForType } from '../../cache/builders/build-decorators';
import { CreateRequest } from '../request.models';
import { NotificationOptions } from '../../../shared/notifications/models/notification-options.model';
import { RequestService } from '../request.service';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import { HALEndpointService } from '../../shared/hal-endpoint.service';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { ObjectCacheService } from '../../cache/object-cache.service';
/**
* Interface for a data service that can create objects.
*/
export interface CreateData<T extends CacheableObject> {
/**
* Create a new DSpaceObject on the server, and store the response
* in the object cache
*
* @param object The object to create
* @param params Array with additional params to combine with query string
*/
create(object: T, ...params: RequestParam[]): Observable<RemoteData<T>>;
}
/**
* A DataService feature to create objects.
*
* Concrete data services can use this feature by implementing {@link CreateData}
* and delegating its method to an inner instance of this class.
*/
export class CreateDataImpl<T extends CacheableObject> extends BaseDataService<T> implements CreateData<T> {
constructor(
protected linkPath: string,
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected responseMsToLive: number,
) {
super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive);
}
/**
* Create a new object on the server, and store the response in the object cache
*
* @param object The object to create
* @param params Array with additional params to combine with query string
*/
create(object: T, ...params: RequestParam[]): Observable<RemoteData<T>> {
const endpoint$ = this.getEndpoint().pipe(
isNotEmptyOperator(),
distinctUntilChanged(),
map((endpoint: string) => this.buildHrefWithParams(endpoint, params)),
);
return this.createOnEndpoint(object, endpoint$);
}
/**
* Send a POST request to create a new resource to a specific endpoint.
* Use this method if the endpoint needs to be adjusted. In most cases {@link create} should be sufficient.
* @param object the object to create
* @param endpoint$ the endpoint to send the POST request to
*/
createOnEndpoint(object: T, endpoint$: Observable<string>): Observable<RemoteData<T>> {
const requestId = this.requestService.generateRequestId();
const serializedObject = new DSpaceSerializer(getClassForType(object.type)).serialize(object);
endpoint$.pipe(
take(1),
).subscribe((endpoint: string) => {
const request = new CreateRequest(requestId, endpoint, JSON.stringify(serializedObject));
if (hasValue(this.responseMsToLive)) {
request.responseMsToLive = this.responseMsToLive;
}
this.requestService.send(request);
});
const result$ = this.rdbService.buildFromRequestUUID<T>(requestId);
// TODO a dataservice is not the best place to show a notification,
// this should move up to the components that use this method
result$.pipe(
takeWhile((rd: RemoteData<T>) => rd.isLoading, true)
).subscribe((rd: RemoteData<T>) => {
if (rd.hasFailed) {
this.notificationsService.error('Server Error:', rd.errorMessage, new NotificationOptions(-1));
}
});
return result$;
}
}

View File

@@ -0,0 +1,55 @@
/* eslint-disable max-classes-per-file */
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { ResourceType } from '../../shared/resource-type';
import { BaseDataService } from './base-data.service';
import { HALDataService } from './hal-data-service.interface';
import { dataService, getDataServiceFor } from './data-service.decorator';
class TestService extends BaseDataService<any> {
}
class AnotherTestService implements HALDataService<any> {
public findListByHref(href$, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow): any {
return undefined;
}
public findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow): any {
return undefined;
}
}
let testType;
describe('@dataService/getDataServiceFor', () => {
beforeEach(() => {
testType = new ResourceType('testType-' + new Date().getTime());
});
it('should register a resourcetype for a dataservice', () => {
dataService(testType)(TestService);
expect(getDataServiceFor(testType)).toBe(TestService);
});
describe(`when the resource type isn't specified`, () => {
it(`should throw an error`, () => {
expect(() => {
dataService(undefined)(TestService);
}).toThrow();
});
});
describe(`when there already is a registered dataservice for a resourcetype`, () => {
it(`should throw an error`, () => {
dataService(testType)(TestService);
expect(() => {
dataService(testType)(AnotherTestService);
}).toThrow();
});
});
});

View File

@@ -0,0 +1,51 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { InjectionToken } from '@angular/core';
import { CacheableObject } from '../../cache/cacheable-object.model';
import { ResourceType } from '../../shared/resource-type';
import { GenericConstructor } from '../../shared/generic-constructor';
import { hasNoValue, hasValue } from '../../../shared/empty.util';
import { HALDataService } from './hal-data-service.interface';
export const DATA_SERVICE_FACTORY = new InjectionToken<(resourceType: ResourceType) => GenericConstructor<HALDataService<any>>>('getDataServiceFor', {
providedIn: 'root',
factory: () => getDataServiceFor,
});
const dataServiceMap = new Map();
/**
* A class decorator to indicate that this class is a data service for a given HAL resource type.
*
* In most cases, a data service should extend {@link BaseDataService}.
* At the very least it must implement {@link HALDataService} in order for it to work with {@link LinkService}.
*
* @param resourceType the resource type the class is a dataservice for
*/
export function dataService(resourceType: ResourceType) {
return (target: GenericConstructor<HALDataService<any>>): void => {
if (hasNoValue(resourceType)) {
throw new Error(`Invalid @dataService annotation on ${target}, resourceType needs to be defined`);
}
const existingDataservice = dataServiceMap.get(resourceType.value);
if (hasValue(existingDataservice)) {
throw new Error(`Multiple dataservices for ${resourceType.value}: ${existingDataservice} and ${target}`);
}
dataServiceMap.set(resourceType.value, target);
};
}
/**
* Return the dataservice matching the given resource type
*
* @param resourceType the resource type you want the matching dataservice for
*/
export function getDataServiceFor<T extends CacheableObject>(resourceType: ResourceType): GenericConstructor<HALDataService<any>> {
return dataServiceMap.get(resourceType.value);
}

View File

@@ -0,0 +1,245 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { constructIdEndpointDefault } from './identifiable-data.service';
import { RequestService } from '../request.service';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../../cache/object-cache.service';
import { HALEndpointService } from '../../shared/hal-endpoint.service';
import { FindListOptions } from '../find-list-options.model';
import { Observable, of as observableOf } from 'rxjs';
import { getMockRequestService } from '../../../shared/mocks/request.service.mock';
import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub';
import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock';
import { followLink } from '../../../shared/utils/follow-link-config.model';
import { TestScheduler } from 'rxjs/testing';
import { RemoteData } from '../remote-data';
import { RequestEntryState } from '../request-entry-state.model';
import { DeleteData, DeleteDataImpl } from './delete-data';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { createFailedRemoteDataObject, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { fakeAsync, tick } from '@angular/core/testing';
/**
* Tests whether calls to `DeleteData` methods are correctly patched through in a concrete data service that implements it
*/
export function testDeleteDataImplementation(serviceFactory: () => DeleteData<any>) {
let service;
describe('DeleteData implementation', () => {
const ID = '2ce78f3a-791b-4d70-b5eb-753d587bbadd';
const HREF = 'https://rest.api/core/items/' + ID;
const COPY_VIRTUAL_METADATA = [
'a', 'b', 'c'
];
beforeAll(() => {
service = serviceFactory();
(service as any).deleteData = jasmine.createSpyObj('deleteData', {
delete: 'TEST delete',
deleteByHref: 'TEST deleteByHref',
});
});
it('should handle calls to delete', () => {
const out: any = service.delete(ID, COPY_VIRTUAL_METADATA);
expect((service as any).deleteData.delete).toHaveBeenCalledWith(ID, COPY_VIRTUAL_METADATA);
expect(out).toBe('TEST delete');
});
it('should handle calls to deleteByHref', () => {
const out: any = service.deleteByHref(HREF, COPY_VIRTUAL_METADATA);
expect((service as any).deleteData.deleteByHref).toHaveBeenCalledWith(HREF, COPY_VIRTUAL_METADATA);
expect(out).toBe('TEST deleteByHref');
});
});
}
const endpoint = 'https://rest.api/core';
const BOOLEAN = { f: false, t: true };
class TestService extends DeleteDataImpl<any> {
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
) {
super(undefined, requestService, rdbService, objectCache, halService, notificationsService, undefined, constructIdEndpointDefault);
}
public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable<string> {
return observableOf(endpoint);
}
}
describe('DeleteDataImpl', () => {
let service: TestService;
let requestService;
let halService;
let rdbService;
let objectCache;
let notificationsService;
let selfLink;
let linksToFollow;
let testScheduler;
let remoteDataMocks;
function initTestService(): TestService {
requestService = getMockRequestService();
halService = new HALEndpointServiceStub('url') as any;
rdbService = getMockRemoteDataBuildService();
objectCache = {
addPatch: () => {
/* empty */
},
getObjectBySelfLink: () => {
/* empty */
},
getByHref: () => {
/* empty */
}
} as any;
notificationsService = {} as NotificationsService;
selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7';
linksToFollow = [
followLink('a'),
followLink('b')
];
testScheduler = new TestScheduler((actual, expected) => {
// asserting the two objects are equal
// e.g. using chai.
expect(actual).toEqual(expected);
});
const timeStamp = new Date().getTime();
const msToLive = 15 * 60 * 1000;
const payload = { foo: 'bar' };
const statusCodeSuccess = 200;
const statusCodeError = 404;
const errorMessage = 'not found';
remoteDataMocks = {
RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined),
ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined),
Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess),
SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess),
Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError),
ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError),
};
return new TestService(
requestService,
rdbService,
objectCache,
halService,
notificationsService,
);
}
beforeEach(() => {
service = initTestService();
});
describe('delete', () => {
let MOCK_SUCCEEDED_RD;
let MOCK_FAILED_RD;
let invalidateByHrefSpy: jasmine.Spy;
let buildFromRequestUUIDSpy: jasmine.Spy;
let getIDHrefObsSpy: jasmine.Spy;
let deleteByHrefSpy: jasmine.Spy;
beforeEach(() => {
invalidateByHrefSpy = spyOn(service, 'invalidateByHref').and.returnValue(observableOf(true));
buildFromRequestUUIDSpy = spyOn(rdbService, 'buildFromRequestUUID').and.callThrough();
getIDHrefObsSpy = spyOn(service, 'getIDHrefObs').and.callThrough();
deleteByHrefSpy = spyOn(service, 'deleteByHref').and.callThrough();
MOCK_SUCCEEDED_RD = createSuccessfulRemoteDataObject({});
MOCK_FAILED_RD = createFailedRemoteDataObject('something went wrong');
});
it('should retrieve href by ID and call deleteByHref', () => {
getIDHrefObsSpy.and.returnValue(observableOf('some-href'));
buildFromRequestUUIDSpy.and.returnValue(createSuccessfulRemoteDataObject$({}));
service.delete('some-id', ['a', 'b', 'c']).subscribe(rd => {
expect(getIDHrefObsSpy).toHaveBeenCalledWith('some-id');
expect(deleteByHrefSpy).toHaveBeenCalledWith('some-href', ['a', 'b', 'c']);
});
});
describe('deleteByHref', () => {
it('should call invalidateByHref if the DELETE request succeeds', (done) => {
buildFromRequestUUIDSpy.and.returnValue(observableOf(MOCK_SUCCEEDED_RD));
service.deleteByHref('some-href').subscribe(rd => {
expect(rd).toBe(MOCK_SUCCEEDED_RD);
expect(invalidateByHrefSpy).toHaveBeenCalled();
done();
});
});
it('should call invalidateByHref even if not subscribing to returned Observable', fakeAsync(() => {
buildFromRequestUUIDSpy.and.returnValue(observableOf(MOCK_SUCCEEDED_RD));
service.deleteByHref('some-href');
tick();
expect(invalidateByHrefSpy).toHaveBeenCalled();
}));
it('should not call invalidateByHref if the DELETE request fails', (done) => {
buildFromRequestUUIDSpy.and.returnValue(observableOf(MOCK_FAILED_RD));
service.deleteByHref('some-href').subscribe(rd => {
expect(rd).toBe(MOCK_FAILED_RD);
expect(invalidateByHrefSpy).not.toHaveBeenCalled();
done();
});
});
it('should wait for invalidateByHref before emitting', () => {
testScheduler.run(({ cold, expectObservable }) => {
buildFromRequestUUIDSpy.and.returnValue(
cold('(r|)', { r: MOCK_SUCCEEDED_RD}) // RD emits right away
);
invalidateByHrefSpy.and.returnValue(
cold('----(t|)', BOOLEAN) // but we pretend that setting requests to stale takes longer
);
const done$ = service.deleteByHref('some-href');
expectObservable(done$).toBe(
'----(r|)', { r: MOCK_SUCCEEDED_RD} // ...and expect the returned Observable to wait until that's done
);
});
});
it('should wait for the DELETE request to resolve before emitting', () => {
testScheduler.run(({ cold, expectObservable }) => {
buildFromRequestUUIDSpy.and.returnValue(
cold('----(r|)', { r: MOCK_SUCCEEDED_RD}) // the request takes a while
);
invalidateByHrefSpy.and.returnValue(
cold('(t|)', BOOLEAN) // but we pretend that setting to stale happens sooner
); // e.g.: maybe already stale before this call?
const done$ = service.deleteByHref('some-href');
expectObservable(done$).toBe(
'----(r|)', { r: MOCK_SUCCEEDED_RD} // ...and expect the returned Observable to wait for the request
);
});
});
});
});
});

View File

@@ -0,0 +1,108 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { CacheableObject } from '../../cache/cacheable-object.model';
import { AsyncSubject, combineLatest, Observable } from 'rxjs';
import { RemoteData } from '../remote-data';
import { NoContent } from '../../shared/NoContent.model';
import { filter, map, switchMap } from 'rxjs/operators';
import { DeleteRequest } from '../request.models';
import { hasNoValue, hasValue } from '../../../shared/empty.util';
import { getFirstCompletedRemoteData } from '../../shared/operators';
import { RequestService } from '../request.service';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import { HALEndpointService } from '../../shared/hal-endpoint.service';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { ObjectCacheService } from '../../cache/object-cache.service';
import { ConstructIdEndpoint, IdentifiableDataService } from './identifiable-data.service';
export interface DeleteData<T extends CacheableObject> {
/**
* Delete an existing object on the server
* @param objectId The id of the object to be removed
* @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual
* metadata should be saved as real metadata
* @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode,
* errorMessage, timeCompleted, etc
*/
delete(objectId: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>>;
/**
* Delete an existing object on the server
* @param href The self link of the object to be removed
* @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual
* metadata should be saved as real metadata
* @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode,
* errorMessage, timeCompleted, etc
* Only emits once all request related to the DSO has been invalidated.
*/
deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>>;
}
export class DeleteDataImpl<T extends CacheableObject> extends IdentifiableDataService<T> implements DeleteData<T> {
constructor(
protected linkPath: string,
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected responseMsToLive: number,
protected constructIdEndpoint: ConstructIdEndpoint,
) {
super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive, constructIdEndpoint);
if (hasNoValue(constructIdEndpoint)) {
throw new Error(`DeleteDataImpl initialized without a constructIdEndpoint method (linkPath: ${linkPath})`);
}
}
delete(objectId: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> {
return this.getIDHrefObs(objectId).pipe(
switchMap((href: string) => this.deleteByHref(href, copyVirtualMetadata)),
);
}
deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> {
const requestId = this.requestService.generateRequestId();
if (copyVirtualMetadata) {
copyVirtualMetadata.forEach((id) =>
href += (href.includes('?') ? '&' : '?')
+ 'copyVirtualMetadata='
+ id,
);
}
const request = new DeleteRequest(requestId, href);
if (hasValue(this.responseMsToLive)) {
request.responseMsToLive = this.responseMsToLive;
}
this.requestService.send(request);
const response$ = this.rdbService.buildFromRequestUUID(requestId);
const invalidated$ = new AsyncSubject<boolean>();
response$.pipe(
getFirstCompletedRemoteData(),
switchMap((rd: RemoteData<NoContent>) => {
if (rd.hasSucceeded) {
return this.invalidateByHref(href);
} else {
return [true];
}
})
).subscribe(() => {
invalidated$.next(true);
invalidated$.complete();
});
return combineLatest([response$, invalidated$]).pipe(
filter(([_, invalidated]) => invalidated),
map(([response, _]) => response),
);
}
}

View File

@@ -0,0 +1,296 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { FindAllData, FindAllDataImpl } from './find-all-data';
import { FindListOptions } from '../find-list-options.model';
import { followLink } from '../../../shared/utils/follow-link-config.model';
import { getMockRequestService } from '../../../shared/mocks/request.service.mock';
import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub';
import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock';
import { TestScheduler } from 'rxjs/testing';
import { RemoteData } from '../remote-data';
import { RequestEntryState } from '../request-entry-state.model';
import { SortDirection, SortOptions } from '../../cache/models/sort-options.model';
import { RequestParam } from '../../cache/models/request-param.model';
import { RequestService } from '../request.service';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import { HALEndpointService } from '../../shared/hal-endpoint.service';
import { ObjectCacheService } from '../../cache/object-cache.service';
import { Observable, of as observableOf } from 'rxjs';
/**
* Tests whether calls to `FindAllData` methods are correctly patched through in a concrete data service that implements it
*/
export function testFindAllDataImplementation(serviceFactory: () => FindAllData<any>) {
let service;
describe('FindAllData implementation', () => {
const OPTIONS = Object.assign(new FindListOptions(), { elementsPerPage: 10, currentPage: 3 });
const FOLLOWLINKS = [
followLink('test'),
followLink('something'),
];
beforeAll(() => {
service = serviceFactory();
(service as any).findAllData = jasmine.createSpyObj('findAllData', {
findAll: 'TEST findAll',
});
});
it('should handle calls to findAll', () => {
const out: any = service.findAll(OPTIONS, false, true, ...FOLLOWLINKS);
expect((service as any).findAllData.findAll).toHaveBeenCalledWith(OPTIONS, false, true, ...FOLLOWLINKS);
expect(out).toBe('TEST findAll');
});
});
}
const endpoint = 'https://rest.api/core';
class TestService extends FindAllDataImpl<any> {
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
) {
super(undefined, requestService, rdbService, objectCache, halService, undefined);
}
public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable<string> {
return observableOf(endpoint);
}
}
describe('FindAllDataImpl', () => {
let service: TestService;
let options: FindListOptions;
let requestService;
let halService;
let rdbService;
let objectCache;
let selfLink;
let linksToFollow;
let testScheduler;
let remoteDataMocks;
function initTestService(): TestService {
requestService = getMockRequestService();
halService = new HALEndpointServiceStub('url') as any;
rdbService = getMockRemoteDataBuildService();
objectCache = {
addPatch: () => {
/* empty */
},
getObjectBySelfLink: () => {
/* empty */
},
getByHref: () => {
/* empty */
},
} as any;
selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7';
linksToFollow = [
followLink('a'),
followLink('b'),
];
testScheduler = new TestScheduler((actual, expected) => {
// asserting the two objects are equal
// e.g. using chai.
expect(actual).toEqual(expected);
});
const timeStamp = new Date().getTime();
const msToLive = 15 * 60 * 1000;
const payload = { foo: 'bar' };
const statusCodeSuccess = 200;
const statusCodeError = 404;
const errorMessage = 'not found';
remoteDataMocks = {
RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined),
ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined),
Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess),
SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess),
Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError),
ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError),
};
return new TestService(
requestService,
rdbService,
objectCache,
halService,
);
}
beforeEach(() => {
service = initTestService();
});
describe('getFindAllHref', () => {
it('should return an observable with the endpoint', () => {
options = {};
(service as any).getFindAllHref(options).subscribe((value) => {
expect(value).toBe(endpoint);
},
);
});
it('should include page in href if currentPage provided in options', () => {
options = { currentPage: 2 };
const expected = `${endpoint}?page=${options.currentPage - 1}`;
(service as any).getFindAllHref(options).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include size in href if elementsPerPage provided in options', () => {
options = { elementsPerPage: 5 };
const expected = `${endpoint}?size=${options.elementsPerPage}`;
(service as any).getFindAllHref(options).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include sort href if SortOptions provided in options', () => {
const sortOptions = new SortOptions('field1', SortDirection.ASC);
options = { sort: sortOptions };
const expected = `${endpoint}?sort=${sortOptions.field},${sortOptions.direction}`;
(service as any).getFindAllHref(options).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include startsWith in href if startsWith provided in options', () => {
options = { startsWith: 'ab' };
const expected = `${endpoint}?startsWith=${options.startsWith}`;
(service as any).getFindAllHref(options).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include all provided options in href', () => {
const sortOptions = new SortOptions('field1', SortDirection.DESC);
options = {
currentPage: 6,
elementsPerPage: 10,
sort: sortOptions,
startsWith: 'ab',
};
const expected = `${endpoint}?page=${options.currentPage - 1}&size=${options.elementsPerPage}` +
`&sort=${sortOptions.field},${sortOptions.direction}&startsWith=${options.startsWith}`;
(service as any).getFindAllHref(options).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include all searchParams in href if any provided in options', () => {
options = {
searchParams: [
new RequestParam('param1', 'test'),
new RequestParam('param2', 'test2'),
],
};
const expected = `${endpoint}?param1=test&param2=test2`;
(service as any).getFindAllHref(options).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include linkPath in href if any provided', () => {
const expected = `${endpoint}/test/entries`;
(service as any).getFindAllHref({}, 'test/entries').subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include single linksToFollow as embed', () => {
const expected = `${endpoint}?embed=bundles`;
(service as any).getFindAllHref({}, null, followLink('bundles')).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include single linksToFollow as embed and its size', () => {
const expected = `${endpoint}?embed.size=bundles=5&embed=bundles`;
const config: FindListOptions = Object.assign(new FindListOptions(), {
elementsPerPage: 5,
});
(service as any).getFindAllHref({}, null, followLink('bundles', { findListOptions: config })).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include multiple linksToFollow as embed', () => {
const expected = `${endpoint}?embed=bundles&embed=owningCollection&embed=templateItemOf`;
(service as any).getFindAllHref({}, null, followLink('bundles'), followLink('owningCollection'), followLink('templateItemOf')).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include multiple linksToFollow as embed and its sizes if given', () => {
const expected = `${endpoint}?embed=bundles&embed.size=owningCollection=2&embed=owningCollection&embed=templateItemOf`;
const config: FindListOptions = Object.assign(new FindListOptions(), {
elementsPerPage: 2,
});
(service as any).getFindAllHref({}, null, followLink('bundles'), followLink('owningCollection', { findListOptions: config }), followLink('templateItemOf')).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should not include linksToFollow with shouldEmbed = false', () => {
const expected = `${endpoint}?embed=templateItemOf`;
(service as any).getFindAllHref(
{},
null,
followLink('bundles', { shouldEmbed: false }),
followLink('owningCollection', { shouldEmbed: false }),
followLink('templateItemOf'),
).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include nested linksToFollow 3lvl', () => {
const expected = `${endpoint}?embed=owningCollection/itemtemplate/relationships`;
(service as any).getFindAllHref({}, null, followLink('owningCollection', {}, followLink('itemtemplate', {}, followLink('relationships')))).subscribe((value) => {
expect(value).toBe(expected);
});
});
it('should include nested linksToFollow 2lvl and nested embed\'s size', () => {
const expected = `${endpoint}?embed.size=owningCollection/itemtemplate=4&embed=owningCollection/itemtemplate`;
const config: FindListOptions = Object.assign(new FindListOptions(), {
elementsPerPage: 4,
});
(service as any).getFindAllHref({}, null, followLink('owningCollection', {}, followLink('itemtemplate', { findListOptions: config }))).subscribe((value) => {
expect(value).toBe(expected);
});
});
});
});

View File

@@ -0,0 +1,101 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Observable } from 'rxjs';
import { FindListOptions } from '../find-list-options.model';
import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
import { RemoteData } from '../remote-data';
import { PaginatedList } from '../paginated-list.model';
import { CacheableObject } from '../../cache/cacheable-object.model';
import { BaseDataService } from './base-data.service';
import { distinctUntilChanged, filter, map } from 'rxjs/operators';
import { isNotEmpty } from '../../../shared/empty.util';
import { RequestService } from '../request.service';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../../cache/object-cache.service';
import { HALEndpointService } from '../../shared/hal-endpoint.service';
/**
* Interface for a data service that list all of its objects.
*/
export interface FindAllData<T extends CacheableObject> {
/**
* Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded
* info should be added to the objects
*
* @param options Find list options object
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
* @return {Observable<RemoteData<PaginatedList<T>>>}
* Return an observable that emits object list
*/
findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<PaginatedList<T>>>;
}
/**
* A DataService feature to list all objects.
*
* Concrete data services can use this feature by implementing {@link FindAllData}
* and delegating its method to an inner instance of this class.
*/
export class FindAllDataImpl<T extends CacheableObject> extends BaseDataService<T> implements FindAllData<T> {
constructor(
protected linkPath: string,
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected responseMsToLive: number,
) {
super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive);
}
/**
* Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded
* info should be added to the objects
*
* @param options Find list options object
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
* @return {Observable<RemoteData<PaginatedList<T>>>}
* Return an observable that emits object list
*/
findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<PaginatedList<T>>> {
return this.findListByHref(this.getFindAllHref(options), options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Create the HREF with given options object
*
* @param options The [[FindListOptions]] object
* @param linkPath The link path for the object
* @return {Observable<string>}
* Return an observable that emits created HREF
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: FollowLinkConfig<T>[]): Observable<string> {
let endpoint$: Observable<string>;
const args = [];
endpoint$ = this.getBrowseEndpoint(options).pipe(
filter((href: string) => isNotEmpty(href)),
map((href: string) => isNotEmpty(linkPath) ? `${href}/${linkPath}` : href),
distinctUntilChanged(),
);
return endpoint$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow)));
}
}

View File

@@ -0,0 +1,41 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Observable } from 'rxjs';
import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
import { RemoteData } from '../remote-data';
import { FindListOptions } from '../find-list-options.model';
import { PaginatedList } from '../paginated-list.model';
import { HALResource } from '../../shared/hal-resource.model';
/**
* An interface defining the minimum functionality needed for a data service to resolve HAL resources.
*/
export interface HALDataService<T extends HALResource> {
/**
* Returns an Observable of {@link RemoteData} of an object, based on an href,
* with a list of {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object
*
* @param href$ The url of object we want to retrieve. Can be a string or an Observable<string>
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's no valid cached version.
* @param reRequestOnStale Whether or not the request should automatically be re-requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
findByHref(href$: string | Observable<string>, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<T>>;
/**
* Returns an Observable of a {@link RemoteData} of a {@link PaginatedList} of objects, based on an href,
* with a list of {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object
*
* @param href$ The url of list we want to retrieve. Can be a string or an Observable<string>
* @param findListOptions The options for to use for this find list request.
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's no valid cached version.
* @param reRequestOnStale Whether or not the request should automatically be re-requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
findListByHref(href$: string | Observable<string>, findListOptions?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<PaginatedList<T>>>;
}

View File

@@ -0,0 +1,145 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { FindListOptions } from '../find-list-options.model';
import { getMockRequestService } from '../../../shared/mocks/request.service.mock';
import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub';
import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock';
import { followLink } from '../../../shared/utils/follow-link-config.model';
import { TestScheduler } from 'rxjs/testing';
import { RemoteData } from '../remote-data';
import { RequestEntryState } from '../request-entry-state.model';
import { Observable, of as observableOf } from 'rxjs';
import { RequestService } from '../request.service';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import { HALEndpointService } from '../../shared/hal-endpoint.service';
import { ObjectCacheService } from '../../cache/object-cache.service';
import { IdentifiableDataService } from './identifiable-data.service';
const endpoint = 'https://rest.api/core';
class TestService extends IdentifiableDataService<any> {
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
) {
super(undefined, requestService, rdbService, objectCache, halService);
}
public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable<string> {
return observableOf(endpoint);
}
}
describe('IdentifiableDataService', () => {
let service: TestService;
let requestService;
let halService;
let rdbService;
let objectCache;
let selfLink;
let linksToFollow;
let testScheduler;
let remoteDataMocks;
function initTestService(): TestService {
requestService = getMockRequestService();
halService = new HALEndpointServiceStub('url') as any;
rdbService = getMockRemoteDataBuildService();
objectCache = {
addPatch: () => {
/* empty */
},
getObjectBySelfLink: () => {
/* empty */
},
getByHref: () => {
/* empty */
}
} as any;
selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7';
linksToFollow = [
followLink('a'),
followLink('b')
];
testScheduler = new TestScheduler((actual, expected) => {
// asserting the two objects are equal
// e.g. using chai.
expect(actual).toEqual(expected);
});
const timeStamp = new Date().getTime();
const msToLive = 15 * 60 * 1000;
const payload = { foo: 'bar' };
const statusCodeSuccess = 200;
const statusCodeError = 404;
const errorMessage = 'not found';
remoteDataMocks = {
RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined),
ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined),
Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess),
SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess),
Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError),
ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError),
};
return new TestService(
requestService,
rdbService,
objectCache,
halService,
);
}
beforeEach(() => {
service = initTestService();
});
describe('getIDHref', () => {
const endpointMock = 'https://dspace7-internal.atmire.com/server/api/core/items';
const resourceIdMock = '003c99b4-d4fe-44b0-a945-e12182a7ca89';
it('should return endpoint', () => {
const result = (service as any).getIDHref(endpointMock, resourceIdMock);
expect(result).toEqual(endpointMock + '/' + resourceIdMock);
});
it('should include single linksToFollow as embed', () => {
const expected = `${endpointMock}/${resourceIdMock}?embed=bundles`;
const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('bundles'));
expect(result).toEqual(expected);
});
it('should include multiple linksToFollow as embed', () => {
const expected = `${endpointMock}/${resourceIdMock}?embed=bundles&embed=owningCollection&embed=templateItemOf`;
const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('bundles'), followLink('owningCollection'), followLink('templateItemOf'));
expect(result).toEqual(expected);
});
it('should not include linksToFollow with shouldEmbed = false', () => {
const expected = `${endpointMock}/${resourceIdMock}?embed=templateItemOf`;
const result = (service as any).getIDHref(
endpointMock,
resourceIdMock,
followLink('bundles', { shouldEmbed: false }),
followLink('owningCollection', { shouldEmbed: false }),
followLink('templateItemOf')
);
expect(result).toEqual(expected);
});
it('should include nested linksToFollow 3lvl', () => {
const expected = `${endpointMock}/${resourceIdMock}?embed=owningCollection/itemtemplate/relationships`;
const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('owningCollection', {}, followLink('itemtemplate', {}, followLink('relationships'))));
expect(result).toEqual(expected);
});
});
});

View File

@@ -0,0 +1,83 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { CacheableObject } from '../../cache/cacheable-object.model';
import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { RemoteData } from '../remote-data';
import { BaseDataService } from './base-data.service';
import { RequestService } from '../request.service';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../../cache/object-cache.service';
import { HALEndpointService } from '../../shared/hal-endpoint.service';
/**
* Shorthand type for the method to construct an ID endpoint.
*/
export type ConstructIdEndpoint = (endpoint: string, resourceID: string) => string;
/**
* The default method to construct an ID endpoint
*/
export const constructIdEndpointDefault = (endpoint, resourceID) => `${endpoint}/${resourceID}`;
/**
* A type of data service that deals with objects that have an ID.
*
* The effective endpoint to use for the ID can be adjusted by providing a different {@link ConstructIdEndpoint} method.
* This method is passed as an argument so that it can be set on data service features without having to override them.
*/
export class IdentifiableDataService<T extends CacheableObject> extends BaseDataService<T> {
constructor(
protected linkPath: string,
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected responseMsToLive?: number,
protected constructIdEndpoint: ConstructIdEndpoint = constructIdEndpointDefault,
) {
super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive);
}
/**
* Returns an observable of {@link RemoteData} of an object, based on its ID, with a list of
* {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object
* @param id ID of object we want to retrieve
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
*/
findById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<T>> {
const href$ = this.getIDHrefObs(encodeURIComponent(id), ...linksToFollow);
return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Create the HREF for a specific object based on its identifier; with possible embed query params based on linksToFollow
* @param endpoint The base endpoint for the type of object
* @param resourceID The identifier for the object
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
getIDHref(endpoint, resourceID, ...linksToFollow: FollowLinkConfig<T>[]): string {
return this.buildHrefFromFindOptions(this.constructIdEndpoint(endpoint, resourceID), {}, [], ...linksToFollow);
}
/**
* Create an observable for the HREF of a specific object based on its identifier
* @param resourceID The identifier for the object
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
getIDHrefObs(resourceID: string, ...linksToFollow: FollowLinkConfig<T>[]): Observable<string> {
return this.getEndpoint().pipe(
map((endpoint: string) => this.getIDHref(endpoint, resourceID, ...linksToFollow)));
}
}

View File

@@ -0,0 +1,235 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
/* eslint-disable max-classes-per-file */
import { RequestService } from '../request.service';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../../cache/object-cache.service';
import { HALEndpointService } from '../../shared/hal-endpoint.service';
import { FindListOptions } from '../find-list-options.model';
import { Observable, of as observableOf } from 'rxjs';
import { getMockRequestService } from '../../../shared/mocks/request.service.mock';
import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub';
import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock';
import { followLink } from '../../../shared/utils/follow-link-config.model';
import { TestScheduler } from 'rxjs/testing';
import { RemoteData } from '../remote-data';
import { RequestEntryState } from '../request-entry-state.model';
import { PatchData, PatchDataImpl } from './patch-data';
import { ChangeAnalyzer } from '../change-analyzer';
import { Item } from '../../shared/item.model';
import { compare, Operation } from 'fast-json-patch';
import { PatchRequest } from '../request.models';
import { DSpaceObject } from '../../shared/dspace-object.model';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { constructIdEndpointDefault } from './identifiable-data.service';
import { RestRequestMethod } from '../rest-request-method';
/**
* Tests whether calls to `PatchData` methods are correctly patched through in a concrete data service that implements it
*/
export function testPatchDataImplementation(serviceFactory: () => PatchData<any>) {
let service;
describe('PatchData implementation', () => {
const OBJ = Object.assign(new DSpaceObject(), {
uuid: '08eec68f-45e4-47a3-80c5-f0beb5627079',
});
const OPERATIONS = [
{ op: 'replace', path: '/0/value', value: 'test' },
{ op: 'add', path: '/2/value', value: 'test2' },
] as Operation[];
const METHOD = RestRequestMethod.POST;
beforeAll(() => {
service = serviceFactory();
(service as any).patchData = jasmine.createSpyObj('patchData', {
patch: 'TEST patch',
update: 'TEST update',
commitUpdates: undefined,
createPatchFromCache: 'TEST createPatchFromCache',
});
});
it('should handle calls to patch', () => {
const out: any = service.patch(OBJ, OPERATIONS);
expect((service as any).patchData.patch).toHaveBeenCalledWith(OBJ, OPERATIONS);
expect(out).toBe('TEST patch');
});
it('should handle calls to update', () => {
const out: any = service.update(OBJ);
expect((service as any).patchData.update).toHaveBeenCalledWith(OBJ);
expect(out).toBe('TEST update');
});
it('should handle calls to commitUpdates', () => {
service.commitUpdates(METHOD);
expect((service as any).patchData.commitUpdates).toHaveBeenCalledWith(METHOD);
});
it('should handle calls to createPatchFromCache', () => {
const out: any = service.createPatchFromCache(OBJ);
expect((service as any).patchData.createPatchFromCache).toHaveBeenCalledWith(OBJ);
expect(out).toBe('TEST createPatchFromCache');
});
});
}
const endpoint = 'https://rest.api/core';
class TestService extends PatchDataImpl<any> {
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected comparator: ChangeAnalyzer<Item>,
) {
super(undefined, requestService, rdbService, objectCache, halService, comparator, undefined, constructIdEndpointDefault);
}
public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable<string> {
return observableOf(endpoint);
}
}
class DummyChangeAnalyzer implements ChangeAnalyzer<Item> {
diff(object1: Item, object2: Item): Operation[] {
return compare((object1 as any).metadata, (object2 as any).metadata);
}
}
describe('PatchDataImpl', () => {
let service: TestService;
let requestService;
let halService;
let rdbService;
let comparator;
let objectCache;
let selfLink;
let linksToFollow;
let testScheduler;
let remoteDataMocks;
function initTestService(): TestService {
requestService = getMockRequestService();
halService = new HALEndpointServiceStub('url') as any;
rdbService = getMockRemoteDataBuildService();
comparator = new DummyChangeAnalyzer() as any;
objectCache = {
addPatch: () => {
/* empty */
},
getObjectBySelfLink: () => {
/* empty */
},
getByHref: () => {
/* empty */
},
} as any;
selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7';
linksToFollow = [
followLink('a'),
followLink('b'),
];
testScheduler = new TestScheduler((actual, expected) => {
// asserting the two objects are equal
// e.g. using chai.
expect(actual).toEqual(expected);
});
const timeStamp = new Date().getTime();
const msToLive = 15 * 60 * 1000;
const payload = { foo: 'bar' };
const statusCodeSuccess = 200;
const statusCodeError = 404;
const errorMessage = 'not found';
remoteDataMocks = {
RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined),
ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined),
Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess),
SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess),
Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError),
ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError),
};
return new TestService(
requestService,
rdbService,
objectCache,
halService,
comparator,
);
}
beforeEach(() => {
service = initTestService();
});
describe('patch', () => {
const dso = {
uuid: 'dso-uuid'
};
const operations = [
Object.assign({
op: 'move',
from: '/1',
path: '/5'
}) as Operation
];
beforeEach((done) => {
service.patch(dso, operations).subscribe(() => {
done();
});
});
it('should send a PatchRequest', () => {
expect(requestService.send).toHaveBeenCalledWith(jasmine.any(PatchRequest));
});
});
describe('update', () => {
let operations;
let dso;
let dso2;
const name1 = 'random string';
const name2 = 'another random string';
beforeEach(() => {
operations = [{ op: 'replace', path: '/0/value', value: name2 } as Operation];
dso = Object.assign(new DSpaceObject(), {
_links: { self: { href: selfLink } },
metadata: [{ key: 'dc.title', value: name1 }]
});
dso2 = Object.assign(new DSpaceObject(), {
_links: { self: { href: selfLink } },
metadata: [{ key: 'dc.title', value: name2 }]
});
spyOn(service, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(dso));
spyOn(objectCache, 'addPatch');
});
it('should call addPatch on the object cache with the right parameters when there are differences', () => {
service.update(dso2).subscribe();
expect(objectCache.addPatch).toHaveBeenCalledWith(selfLink, operations);
});
it('should not call addPatch on the object cache with the right parameters when there are no differences', () => {
service.update(dso).subscribe();
expect(objectCache.addPatch).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,143 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { CacheableObject } from '../../cache/cacheable-object.model';
import { Operation } from 'fast-json-patch';
import { Observable } from 'rxjs';
import { RemoteData } from '../remote-data';
import { find, map, mergeMap } from 'rxjs/operators';
import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util';
import { PatchRequest } from '../request.models';
import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../../shared/operators';
import { ChangeAnalyzer } from '../change-analyzer';
import { RequestService } from '../request.service';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import { HALEndpointService } from '../../shared/hal-endpoint.service';
import { ObjectCacheService } from '../../cache/object-cache.service';
import { RestRequestMethod } from '../rest-request-method';
import { ConstructIdEndpoint, IdentifiableDataService } from './identifiable-data.service';
/**
* Interface for a data service that can patch and update objects.
*/
export interface PatchData<T extends CacheableObject> {
/**
* Send a patch request for a specified object
* @param {T} object The object to send a patch request for
* @param {Operation[]} operations The patch operations to be performed
*/
patch(object: T, operations: Operation[]): Observable<RemoteData<T>>;
/**
* Add a new patch to the object cache
* The patch is derived from the differences between the given object and its version in the object cache
* @param {DSpaceObject} object The given object
*/
update(object: T): Observable<RemoteData<T>>;
/**
* Commit current object changes to the server
* @param method The RestRequestMethod for which de server sync buffer should be committed
*/
commitUpdates(method?: RestRequestMethod): void;
/**
* Return a list of operations representing the difference between an object and its latest value in the cache.
* @param object the object to resolve to a list of patch operations
*/
createPatchFromCache(object: T): Observable<Operation[]>;
}
/**
* A DataService feature to patch and update objects.
*
* Concrete data services can use this feature by implementing {@link PatchData}
* and delegating its method to an inner instance of this class.
*
* Note that this feature requires the object in question to have an ID.
* Make sure to use the same {@link ConstructIdEndpoint} as in the parent data service.
*/
export class PatchDataImpl<T extends CacheableObject> extends IdentifiableDataService<T> implements PatchData<T> {
constructor(
protected linkPath: string,
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected comparator: ChangeAnalyzer<T>,
protected responseMsToLive: number,
protected constructIdEndpoint: ConstructIdEndpoint,
) {
super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive, constructIdEndpoint);
if (hasNoValue(constructIdEndpoint)) {
throw new Error(`PatchDataImpl initialized without a constructIdEndpoint method (linkPath: ${linkPath})`);
}
}
/**
* Send a patch request for a specified object
* @param {T} object The object to send a patch request for
* @param {Operation[]} operations The patch operations to be performed
*/
patch(object: T, operations: Operation[]): Observable<RemoteData<T>> {
const requestId = this.requestService.generateRequestId();
const hrefObs = this.halService.getEndpoint(this.linkPath).pipe(
map((endpoint: string) => this.getIDHref(endpoint, object.uuid)),
);
hrefObs.pipe(
find((href: string) => hasValue(href)),
).subscribe((href: string) => {
const request = new PatchRequest(requestId, href, operations);
if (hasValue(this.responseMsToLive)) {
request.responseMsToLive = this.responseMsToLive;
}
this.requestService.send(request);
});
return this.rdbService.buildFromRequestUUID(requestId);
}
/**
* Add a new patch to the object cache
* The patch is derived from the differences between the given object and its version in the object cache
* @param {DSpaceObject} object The given object
*/
update(object: T): Observable<RemoteData<T>> {
return this.createPatchFromCache(object).pipe(
mergeMap((operations: Operation[]) => {
if (isNotEmpty(operations)) {
this.objectCache.addPatch(object._links.self.href, operations);
}
return this.findByHref(object._links.self.href, true, true);
}),
);
}
/**
* Commit current object changes to the server
* @param method The RestRequestMethod for which de server sync buffer should be committed
*/
commitUpdates(method?: RestRequestMethod): void {
this.requestService.commit(method);
}
/**
* Return a list of operations representing the difference between an object and its latest value in the cache.
* @param object the object to resolve to a list of patch operations
*/
createPatchFromCache(object: T): Observable<Operation[]> {
const oldVersion$ = this.findByHref(object._links.self.href, true, false);
return oldVersion$.pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
map((oldVersion: T) => this.comparator.diff(oldVersion, object)),
);
}
}

View File

@@ -0,0 +1,176 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { RequestService } from '../request.service';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../../cache/object-cache.service';
import { HALEndpointService } from '../../shared/hal-endpoint.service';
import { FindListOptions } from '../find-list-options.model';
import { Observable, of as observableOf } from 'rxjs';
import { getMockRequestService } from '../../../shared/mocks/request.service.mock';
import { HALEndpointServiceStub } from '../../../shared/testing/hal-endpoint-service.stub';
import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock';
import { RemoteData } from '../remote-data';
import { RequestEntryState } from '../request-entry-state.model';
import { PutData, PutDataImpl } from './put-data';
import { RestRequestMethod } from '../rest-request-method';
import { DSpaceObject } from '../../shared/dspace-object.model';
/**
* Tests whether calls to `PutData` methods are correctly patched through in a concrete data service that implements it
*/
export function testPutDataImplementation(serviceFactory: () => PutData<any>) {
let service;
describe('PutData implementation', () => {
const OBJ = Object.assign(new DSpaceObject(), {
uuid: '08eec68f-45e4-47a3-80c5-f0beb5627079',
});
beforeAll(() => {
service = serviceFactory();
(service as any).putData = jasmine.createSpyObj('putData', {
put: 'TEST put',
});
});
it('should handle calls to put', () => {
const out: any = service.put(OBJ);
expect((service as any).putData.put).toHaveBeenCalledWith(OBJ);
expect(out).toBe('TEST put');
});
});
}
const endpoint = 'https://rest.api/core';
class TestService extends PutDataImpl<any> {
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
) {
super(undefined, requestService, rdbService, objectCache, halService, undefined);
}
public getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable<string> {
return observableOf(endpoint);
}
}
describe('PutDataImpl', () => {
let service: TestService;
let requestService;
let halService;
let rdbService;
let objectCache;
let selfLink;
let remoteDataMocks;
let obj;
let buildFromRequestUUIDSpy: jasmine.Spy;
function initTestService(): TestService {
requestService = getMockRequestService();
halService = new HALEndpointServiceStub('url') as any;
rdbService = getMockRemoteDataBuildService();
objectCache = {
addPatch: () => {
/* empty */
},
getObjectBySelfLink: () => {
/* empty */
},
getByHref: () => {
/* empty */
},
} as any;
selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7';
const timeStamp = new Date().getTime();
const msToLive = 15 * 60 * 1000;
const payload = { foo: 'bar' };
const statusCodeSuccess = 200;
const statusCodeError = 404;
const errorMessage = 'not found';
remoteDataMocks = {
RequestPending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.RequestPending, undefined, undefined, undefined),
ResponsePending: new RemoteData(undefined, msToLive, timeStamp, RequestEntryState.ResponsePending, undefined, undefined, undefined),
Success: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Success, undefined, payload, statusCodeSuccess),
SuccessStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.SuccessStale, undefined, payload, statusCodeSuccess),
Error: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.Error, errorMessage, undefined, statusCodeError),
ErrorStale: new RemoteData(timeStamp, msToLive, timeStamp, RequestEntryState.ErrorStale, errorMessage, undefined, statusCodeError),
};
return new TestService(
requestService,
rdbService,
objectCache,
halService,
);
}
beforeEach(() => {
service = initTestService();
obj = Object.assign(new DSpaceObject(), {
uuid: '1698f1d3-be98-4c51-9fd8-6bfedcbd59b7',
metadata: { // recognized properties will be serialized
['dc.title']: [
{ language: 'en', value: 'some object' },
]
},
data: [ 1, 2, 3, 4 ], // unrecognized properties won't be serialized
_links: { self: { href: selfLink } },
});
buildFromRequestUUIDSpy = spyOn(rdbService, 'buildFromRequestUUID').and.returnValue(observableOf(remoteDataMocks.Success));
});
describe('put', () => {
it('should send a PUT request with the serialized object', (done) => {
service.put(obj).subscribe(() => {
expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({
method: RestRequestMethod.PUT,
body: { // _links are not serialized
uuid: obj.uuid,
metadata: obj.metadata
},
}));
done();
});
});
it('should send the PUT request to the object\'s self link', (done) => {
service.put(obj).subscribe(() => {
expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({
method: RestRequestMethod.PUT,
href: selfLink,
}));
done();
});
});
it('should return the remote data for the sent request', (done) => {
service.put(obj).subscribe(out => {
expect(requestService.send).toHaveBeenCalledWith(jasmine.objectContaining({
method: RestRequestMethod.PUT,
uuid: requestService.generateRequestId(),
}));
expect(buildFromRequestUUIDSpy).toHaveBeenCalledWith(requestService.generateRequestId());
expect(out).toEqual(remoteDataMocks.Success);
done();
});
});
});
});

View File

@@ -0,0 +1,69 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { CacheableObject } from '../../cache/cacheable-object.model';
import { BaseDataService } from './base-data.service';
import { Observable } from 'rxjs';
import { RemoteData } from '../remote-data';
import { DSpaceSerializer } from '../../dspace-rest/dspace.serializer';
import { GenericConstructor } from '../../shared/generic-constructor';
import { PutRequest } from '../request.models';
import { hasValue } from '../../../shared/empty.util';
import { RequestService } from '../request.service';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../../cache/object-cache.service';
import { HALEndpointService } from '../../shared/hal-endpoint.service';
/**
* Interface for a data service that can send PUT requests.
*/
export interface PutData<T extends CacheableObject> {
/**
* Send a PUT request for the specified object
*
* @param object The object to send a put request for.
*/
put(object: T): Observable<RemoteData<T>>;
}
/**
* A DataService feature to send PUT requests.
*
* Concrete data services can use this feature by implementing {@link PutData}
* and delegating its method to an inner instance of this class.
*/
export class PutDataImpl<T extends CacheableObject> extends BaseDataService<T> implements PutData<T> {
constructor(
protected linkPath: string,
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected responseMsToLive: number,
) {
super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive);
}
/**
* Send a PUT request for the specified object
*
* @param object The object to send a put request for.
*/
put(object: T): Observable<RemoteData<T>> {
const requestId = this.requestService.generateRequestId();
const serializedObject = new DSpaceSerializer(object.constructor as GenericConstructor<{}>).serialize(object);
const request = new PutRequest(requestId, object._links.self.href, serializedObject);
if (hasValue(this.responseMsToLive)) {
request.responseMsToLive = this.responseMsToLive;
}
this.requestService.send(request);
return this.rdbService.buildFromRequestUUID(requestId);
}
}

View File

@@ -0,0 +1,141 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { constructSearchEndpointDefault, SearchData, SearchDataImpl } from './search-data';
import { FindListOptions } from '../find-list-options.model';
import { followLink } from '../../../shared/utils/follow-link-config.model';
import { of as observableOf } from 'rxjs';
import { getMockRequestService } from '../../../shared/mocks/request.service.mock';
import { getMockRemoteDataBuildService } from '../../../shared/mocks/remote-data-build.service.mock';
/**
* Tests whether calls to `SearchData` methods are correctly patched through in a concrete data service that implements it
*/
export function testSearchDataImplementation(serviceFactory: () => SearchData<any>) {
let service;
describe('SearchData implementation', () => {
const OPTIONS = Object.assign(new FindListOptions(), { elementsPerPage: 10, currentPage: 3 });
const FOLLOWLINKS = [
followLink('test'),
followLink('something'),
];
beforeAll(() => {
service = serviceFactory();
(service as any).searchData = jasmine.createSpyObj('searchData', {
searchBy: 'TEST searchBy',
});
});
it('should handle calls to searchBy', () => {
const out: any = service.searchBy('searchMethod', OPTIONS, false, true, ...FOLLOWLINKS);
expect((service as any).searchData.searchBy).toHaveBeenCalledWith('searchMethod', OPTIONS, false, true, ...FOLLOWLINKS);
expect(out).toBe('TEST searchBy');
});
});
}
const endpoint = 'https://rest.api/core';
describe('SearchDataImpl', () => {
let service: SearchDataImpl<any>;
let requestService;
let halService;
let rdbService;
let linksToFollow;
let constructSearchEndpointSpy;
let options;
function initTestService(): SearchDataImpl<any> {
requestService = getMockRequestService();
halService = jasmine.createSpyObj('halService', {
getEndpoint: observableOf(endpoint),
});
rdbService = getMockRemoteDataBuildService();
linksToFollow = [
followLink('a'),
followLink('b'),
];
constructSearchEndpointSpy = jasmine.createSpy('constructSearchEndpointSpy').and.callFake(constructSearchEndpointDefault);
options = Object.assign(new FindListOptions(), {
elementsPerPage: 5,
currentPage: 3,
});
return new SearchDataImpl(
'test',
requestService,
rdbService,
undefined,
halService,
undefined,
constructSearchEndpointSpy,
);
}
beforeEach(() => {
service = initTestService();
});
describe('getSearchEndpoint', () => {
it('should return the search endpoint for the given method', (done) => {
(service as any).getSearchEndpoint('testMethod').subscribe(searchEndpoint => {
expect(halService.getEndpoint).toHaveBeenCalledWith('test');
expect(searchEndpoint).toBe('https://rest.api/core/search/testMethod');
done();
});
});
it('should use constructSearchEndpoint to construct the search endpoint', (done) => {
(service as any).getSearchEndpoint('testMethod').subscribe(() => {
expect(constructSearchEndpointSpy).toHaveBeenCalledWith('https://rest.api/core', 'testMethod');
done();
});
});
});
describe('getSearchByHref', () => {
beforeEach(() => {
spyOn(service as any, 'getSearchEndpoint').and.callThrough();
spyOn(service, 'buildHrefFromFindOptions').and.callThrough();
});
it('should return the search endpoint with additional query parameters', (done) => {
service.getSearchByHref('testMethod', options, ...linksToFollow).subscribe(href => {
expect((service as any).getSearchEndpoint).toHaveBeenCalledWith('testMethod');
expect(service.buildHrefFromFindOptions).toHaveBeenCalledWith(
'https://rest.api/core/search/testMethod',
options,
[],
...linksToFollow,
);
expect(href).toBe('https://rest.api/core/search/testMethod?page=2&size=5&embed=a&embed=b');
done();
});
});
});
describe('searchBy', () => {
it('should patch getSearchEndpoint into findListByHref and return the result', () => {
spyOn(service, 'getSearchByHref').and.returnValue('endpoint' as any);
spyOn(service, 'findListByHref').and.returnValue('resulting remote data' as any);
const out: any = service.searchBy('testMethod', options, false, true, ...linksToFollow);
expect(service.getSearchByHref).toHaveBeenCalledWith('testMethod', options, ...linksToFollow);
expect(service.findListByHref).toHaveBeenCalledWith('endpoint', undefined, false, true, ...linksToFollow);
expect(out).toBe('resulting remote data');
});
});
});

View File

@@ -0,0 +1,134 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { CacheableObject } from '../../cache/cacheable-object.model';
import { BaseDataService } from './base-data.service';
import { Observable } from 'rxjs';
import { filter, map } from 'rxjs/operators';
import { hasNoValue, isNotEmpty } from '../../../shared/empty.util';
import { FindListOptions } from '../find-list-options.model';
import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
import { RemoteData } from '../remote-data';
import { PaginatedList } from '../paginated-list.model';
import { RequestService } from '../request.service';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../../cache/object-cache.service';
import { HALEndpointService } from '../../shared/hal-endpoint.service';
/**
* Shorthand type for method to construct a search endpoint
*/
export type ConstructSearchEndpoint = (href: string, searchMethod: string) => string;
/**
* Default method to construct a search endpoint
*/
export const constructSearchEndpointDefault = (href: string, searchMethod: string): string => `${href}/search/${searchMethod}`;
/**
* Interface for a data service that can search for objects.
*/
export interface SearchData<T extends CacheableObject> {
/**
* Make a new FindListRequest with given search method
*
* @param searchMethod The search method for the object
* @param options The [[FindListOptions]] object
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
* @return {Observable<RemoteData<PaginatedList<T>>}
* Return an observable that emits response from the server
*/
searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<PaginatedList<T>>>;
}
/**
* A DataService feature to search for objects.
*
* Concrete data services can use this feature by implementing {@link SearchData}
* and delegating its method to an inner instance of this class.
*/
export class SearchDataImpl<T extends CacheableObject> extends BaseDataService<T> implements SearchData<T> {
/**
* @param linkPath
* @param requestService
* @param rdbService
* @param objectCache
* @param halService
* @param responseMsToLive
* @param constructSearchEndpoint an optional method to construct the search endpoint, passed as an argument so it can be
* modified without extending this class. Defaults to `${href}/search/${searchMethod}`
*/
constructor(
protected linkPath: string,
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected responseMsToLive: number,
private constructSearchEndpoint: ConstructSearchEndpoint = constructSearchEndpointDefault,
) {
super(linkPath, requestService, rdbService, objectCache, halService, responseMsToLive);
if (hasNoValue(constructSearchEndpoint)) {
throw new Error(`SearchDataImpl initialized without a constructSearchEndpoint method (linkPath: ${linkPath})`);
}
}
/**
* Make a new FindListRequest with given search method
*
* @param searchMethod The search method for the object
* @param options The [[FindListOptions]] object
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
* @return {Observable<RemoteData<PaginatedList<T>>}
* Return an observable that emits response from the server
*/
searchBy(searchMethod: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<PaginatedList<T>>> {
const hrefObs = this.getSearchByHref(searchMethod, options, ...linksToFollow);
return this.findListByHref(hrefObs, undefined, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Create the HREF for a specific object's search method with given options object
*
* @param searchMethod The search method for the object
* @param options The [[FindListOptions]] object
* @return {Observable<string>}
* Return an observable that emits created HREF
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig<T>[]): Observable<string> {
let result$: Observable<string>;
const args = [];
result$ = this.getSearchEndpoint(searchMethod);
return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow)));
}
/**
* Return object search endpoint by given search method
*
* @param searchMethod The search method for the object
*/
private getSearchEndpoint(searchMethod: string): Observable<string> {
return this.halService.getEndpoint(this.linkPath).pipe(
filter((href: string) => isNotEmpty(href)),
map(href => this.constructSearchEndpoint(href, searchMethod)),
);
}
}

View File

@@ -12,6 +12,9 @@ import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock'; import { getMockRemoteDataBuildService } from '../../shared/mocks/remote-data-build.service.mock';
import { testSearchDataImplementation } from './base/search-data.spec';
import { testPatchDataImplementation } from './base/patch-data.spec';
import { testDeleteDataImplementation } from './base/delete-data.spec';
describe('BitstreamDataService', () => { describe('BitstreamDataService', () => {
let service: BitstreamDataService; let service: BitstreamDataService;
@@ -47,7 +50,14 @@ describe('BitstreamDataService', () => {
}); });
rdbService = getMockRemoteDataBuildService(); rdbService = getMockRemoteDataBuildService();
service = new BitstreamDataService(requestService, rdbService, null, objectCache, halService, null, null, null, null, bitstreamFormatService); service = new BitstreamDataService(requestService, rdbService, objectCache, halService, null, bitstreamFormatService, null, null);
});
describe('composition', () => {
const initService = () => new BitstreamDataService(null, null, null, null, null, null, null, null);
testSearchDataImplementation(initService);
testPatchDataImplementation(initService);
testDeleteDataImplementation(initService);
}); });
describe('when updating the bitstream\'s format', () => { describe('when updating the bitstream\'s format', () => {

View File

@@ -1,12 +1,9 @@
import { HttpClient, HttpHeaders } from '@angular/common/http'; import { HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { map, switchMap, take } from 'rxjs/operators'; import { map, switchMap, take } from 'rxjs/operators';
import { hasValue } from '../../shared/empty.util'; import { hasValue } from '../../shared/empty.util';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { dataService } from '../cache/builders/build-decorators';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { Bitstream } from '../shared/bitstream.model'; import { Bitstream } from '../shared/bitstream.model';
@@ -15,8 +12,6 @@ import { Bundle } from '../shared/bundle.model';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { Item } from '../shared/item.model'; import { Item } from '../shared/item.model';
import { BundleDataService } from './bundle-data.service'; import { BundleDataService } from './bundle-data.service';
import { DataService } from './data.service';
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
import { buildPaginatedList, PaginatedList } from './paginated-list.model'; import { buildPaginatedList, PaginatedList } from './paginated-list.model';
import { RemoteData } from './remote-data'; import { RemoteData } from './remote-data';
import { PutRequest } from './request.models'; import { PutRequest } from './request.models';
@@ -28,36 +23,45 @@ import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.util
import { PageInfo } from '../shared/page-info.model'; import { PageInfo } from '../shared/page-info.model';
import { RequestParam } from '../cache/models/request-param.model'; import { RequestParam } from '../cache/models/request-param.model';
import { sendRequest } from '../shared/request.operators'; import { sendRequest } from '../shared/request.operators';
import { CoreState } from '../core-state.model';
import { FindListOptions } from './find-list-options.model'; import { FindListOptions } from './find-list-options.model';
import { SearchData, SearchDataImpl } from './base/search-data';
import { PatchData, PatchDataImpl } from './base/patch-data';
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
import { RestRequestMethod } from './rest-request-method';
import { DeleteData, DeleteDataImpl } from './base/delete-data';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { NoContent } from '../shared/NoContent.model';
import { IdentifiableDataService } from './base/identifiable-data.service';
import { dataService } from './base/data-service.decorator';
import { Operation } from 'fast-json-patch';
/** /**
* A service to retrieve {@link Bitstream}s from the REST API * A service to retrieve {@link Bitstream}s from the REST API
*/ */
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
@dataService(BITSTREAM) @dataService(BITSTREAM)
export class BitstreamDataService extends DataService<Bitstream> { export class BitstreamDataService extends IdentifiableDataService<Bitstream> implements SearchData<Bitstream>, PatchData<Bitstream>, DeleteData<Bitstream> {
private searchData: SearchDataImpl<Bitstream>;
/** private patchData: PatchDataImpl<Bitstream>;
* The HAL path to the bitstream endpoint private deleteData: DeleteDataImpl<Bitstream>;
*/
protected linkPath = 'bitstreams';
constructor( constructor(
protected requestService: RequestService, protected requestService: RequestService,
protected rdbService: RemoteDataBuildService, protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
protected halService: HALEndpointService, protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DSOChangeAnalyzer<Bitstream>,
protected bundleService: BundleDataService, protected bundleService: BundleDataService,
protected bitstreamFormatService: BitstreamFormatDataService protected bitstreamFormatService: BitstreamFormatDataService,
protected comparator: DSOChangeAnalyzer<Bitstream>,
protected notificationsService: NotificationsService,
) { ) {
super(); super('bitstreams', requestService, rdbService, objectCache, halService);
this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
this.patchData = new PatchDataImpl<Bitstream>(this.linkPath, requestService, rdbService, objectCache, halService, comparator, this.responseMsToLive, this.constructIdEndpoint);
this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint);
} }
/** /**
@@ -73,7 +77,7 @@ export class BitstreamDataService extends DataService<Bitstream> {
* {@link HALLink}s should be automatically resolved * {@link HALLink}s should be automatically resolved
*/ */
findAllByBundle(bundle: Bundle, options?: FindListOptions, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<Bitstream>[]): Observable<RemoteData<PaginatedList<Bitstream>>> { findAllByBundle(bundle: Bundle, options?: FindListOptions, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<Bitstream>[]): Observable<RemoteData<PaginatedList<Bitstream>>> {
return this.findAllByHref(bundle._links.bitstreams.href, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); return this.findListByHref(bundle._links.bitstreams.href, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
} }
/** /**
@@ -180,8 +184,97 @@ export class BitstreamDataService extends DataService<Bitstream> {
hrefObs, hrefObs,
useCachedVersionIfAvailable, useCachedVersionIfAvailable,
reRequestOnStale, reRequestOnStale,
...linksToFollow ...linksToFollow,
); );
} }
/**
* Create the HREF for a specific object's search method with given options object
*
* @param searchMethod The search method for the object
* @param options The [[FindListOptions]] object
* @return {Observable<string>}
* Return an observable that emits created HREF
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
public getSearchByHref(searchMethod: string, options?: FindListOptions, ...linksToFollow: FollowLinkConfig<Bitstream>[]): Observable<string> {
return this.searchData.getSearchByHref(searchMethod, options, ...linksToFollow);
}
/**
* Make a new FindListRequest with given search method
*
* @param searchMethod The search method for the object
* @param options The [[FindListOptions]] object
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
* @return {Observable<RemoteData<PaginatedList<T>>}
* Return an observable that emits response from the server
*/
public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<Bitstream>[]): Observable<RemoteData<PaginatedList<Bitstream>>> {
return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Commit current object changes to the server
* @param method The RestRequestMethod for which de server sync buffer should be committed
*/
public commitUpdates(method?: RestRequestMethod): void {
this.patchData.commitUpdates(method);
}
/**
* Send a patch request for a specified object
* @param {T} object The object to send a patch request for
* @param {Operation[]} operations The patch operations to be performed
*/
public patch(object: Bitstream, operations: []): Observable<RemoteData<Bitstream>> {
return this.patchData.patch(object, operations);
}
/**
* Add a new patch to the object cache
* The patch is derived from the differences between the given object and its version in the object cache
* @param {DSpaceObject} object The given object
*/
public update(object: Bitstream): Observable<RemoteData<Bitstream>> {
return this.patchData.update(object);
}
/**
* Return a list of operations representing the difference between an object and its latest value in the cache.
* @param object the object to resolve to a list of patch operations
*/
public createPatchFromCache(object: Bitstream): Observable<Operation[]> {
return this.patchData.createPatchFromCache(object);
}
/**
* Delete an existing object on the server
* @param objectId The id of the object to be removed
* @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual
* metadata should be saved as real metadata
* @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode,
* errorMessage, timeCompleted, etc
*/
delete(objectId: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> {
return this.deleteData.delete(objectId, copyVirtualMetadata);
}
/**
* Delete an existing object on the server
* @param href The self link of the object to be removed
* @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual
* metadata should be saved as real metadata
* @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode,
* errorMessage, timeCompleted, etc
* Only emits once all request related to the DSO has been invalidated.
*/
deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> {
return this.deleteData.deleteByHref(href, copyVirtualMetadata);
}
} }

View File

@@ -6,19 +6,16 @@ import { ObjectCacheService } from '../cache/object-cache.service';
import { cold, getTestScheduler, hot } from 'jasmine-marbles'; import { cold, getTestScheduler, hot } from 'jasmine-marbles';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { BitstreamFormat } from '../shared/bitstream-format.model'; import { BitstreamFormat } from '../shared/bitstream-format.model';
import { waitForAsync } from '@angular/core/testing'; import { waitForAsync } from '@angular/core/testing';
import { import { BitstreamFormatsRegistryDeselectAction, BitstreamFormatsRegistryDeselectAllAction, BitstreamFormatsRegistrySelectAction } from '../../admin/admin-registries/bitstream-formats/bitstream-format.actions';
BitstreamFormatsRegistryDeselectAction,
BitstreamFormatsRegistryDeselectAllAction,
BitstreamFormatsRegistrySelectAction
} from '../../admin/admin-registries/bitstream-formats/bitstream-format.actions';
import { TestScheduler } from 'rxjs/testing'; import { TestScheduler } from 'rxjs/testing';
import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils';
import { CoreState } from '../core-state.model'; import { CoreState } from '../core-state.model';
import { RequestEntry } from './request-entry.model'; import { RequestEntry } from './request-entry.model';
import { testFindAllDataImplementation } from './base/find-all-data.spec';
import { testDeleteDataImplementation } from './base/delete-data.spec';
describe('BitstreamFormatDataService', () => { describe('BitstreamFormatDataService', () => {
let service: BitstreamFormatDataService; let service: BitstreamFormatDataService;
@@ -50,8 +47,6 @@ describe('BitstreamFormatDataService', () => {
} as HALEndpointService; } as HALEndpointService;
const notificationsService = {} as NotificationsService; const notificationsService = {} as NotificationsService;
const http = {} as HttpClient;
const comparator = {} as any;
let rd; let rd;
let rdbService: RemoteDataBuildService; let rdbService: RemoteDataBuildService;
@@ -65,15 +60,19 @@ describe('BitstreamFormatDataService', () => {
return new BitstreamFormatDataService( return new BitstreamFormatDataService(
requestService, requestService,
rdbService, rdbService,
store,
objectCache, objectCache,
halService, halService,
notificationsService, notificationsService,
http, store,
comparator
); );
} }
describe('composition', () => {
const initService = () => new BitstreamFormatDataService(null, null, null, null, null, null);
testFindAllDataImplementation(initService);
testDeleteDataImplementation(initService);
});
describe('getBrowseEndpoint', () => { describe('getBrowseEndpoint', () => {
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
scheduler = getTestScheduler(); scheduler = getTestScheduler();

View File

@@ -1,16 +1,10 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { createSelector, select, Store } from '@ngrx/store'; import { createSelector, select, Store } from '@ngrx/store';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { distinctUntilChanged, map, tap } from 'rxjs/operators'; import { distinctUntilChanged, map, tap } from 'rxjs/operators';
import { import { BitstreamFormatsRegistryDeselectAction, BitstreamFormatsRegistryDeselectAllAction, BitstreamFormatsRegistrySelectAction } from '../../admin/admin-registries/bitstream-formats/bitstream-format.actions';
BitstreamFormatsRegistryDeselectAction,
BitstreamFormatsRegistryDeselectAllAction,
BitstreamFormatsRegistrySelectAction
} from '../../admin/admin-registries/bitstream-formats/bitstream-format.actions';
import { BitstreamFormatRegistryState } from '../../admin/admin-registries/bitstream-formats/bitstream-format.reducers'; import { BitstreamFormatRegistryState } from '../../admin/admin-registries/bitstream-formats/bitstream-format.reducers';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
import { dataService } from '../cache/builders/build-decorators';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { coreSelector } from '../core.selectors'; import { coreSelector } from '../core.selectors';
@@ -18,40 +12,53 @@ import { BitstreamFormat } from '../shared/bitstream-format.model';
import { BITSTREAM_FORMAT } from '../shared/bitstream-format.resource-type'; import { BITSTREAM_FORMAT } from '../shared/bitstream-format.resource-type';
import { Bitstream } from '../shared/bitstream.model'; import { Bitstream } from '../shared/bitstream.model';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { DataService } from './data.service';
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
import { RemoteData } from './remote-data'; import { RemoteData } from './remote-data';
import { PostRequest, PutRequest } from './request.models'; import { PostRequest, PutRequest } from './request.models';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
import { sendRequest } from '../shared/request.operators'; import { sendRequest } from '../shared/request.operators';
import { CoreState } from '../core-state.model'; import { CoreState } from '../core-state.model';
import { IdentifiableDataService } from './base/identifiable-data.service';
import { DeleteData, DeleteDataImpl } from './base/delete-data';
import { FindAllData, FindAllDataImpl } from './base/find-all-data';
import { FollowLinkConfig } from 'src/app/shared/utils/follow-link-config.model';
import { FindListOptions } from './find-list-options.model';
import { PaginatedList } from './paginated-list.model';
import { NoContent } from '../shared/NoContent.model';
import { dataService } from './base/data-service.decorator';
const bitstreamFormatsStateSelector = createSelector( const bitstreamFormatsStateSelector = createSelector(
coreSelector, coreSelector,
(state: CoreState) => state.bitstreamFormats (state: CoreState) => state.bitstreamFormats,
);
const selectedBitstreamFormatSelector = createSelector(
bitstreamFormatsStateSelector,
(bitstreamFormatRegistryState: BitstreamFormatRegistryState) => bitstreamFormatRegistryState.selectedBitstreamFormats,
); );
const selectedBitstreamFormatSelector = createSelector(bitstreamFormatsStateSelector,
(bitstreamFormatRegistryState: BitstreamFormatRegistryState) => bitstreamFormatRegistryState.selectedBitstreamFormats);
/** /**
* A service responsible for fetching/sending data from/to the REST API on the bitstreamformats endpoint * A service responsible for fetching/sending data from/to the REST API on the bitstreamformats endpoint
*/ */
@Injectable() @Injectable()
@dataService(BITSTREAM_FORMAT) @dataService(BITSTREAM_FORMAT)
export class BitstreamFormatDataService extends DataService<BitstreamFormat> { export class BitstreamFormatDataService extends IdentifiableDataService<BitstreamFormat> implements FindAllData<BitstreamFormat>, DeleteData<BitstreamFormat> {
protected linkPath = 'bitstreamformats'; protected linkPath = 'bitstreamformats';
private findAllData: FindAllDataImpl<BitstreamFormat>;
private deleteData: DeleteDataImpl<BitstreamFormat>;
constructor( constructor(
protected requestService: RequestService, protected requestService: RequestService,
protected rdbService: RemoteDataBuildService, protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
protected halService: HALEndpointService, protected halService: HALEndpointService,
protected notificationsService: NotificationsService, protected notificationsService: NotificationsService,
protected http: HttpClient, protected store: Store<CoreState>,
protected comparator: DefaultChangeAnalyzer<BitstreamFormat>) { ) {
super(); super('bitstreamformats', requestService, rdbService, objectCache, halService);
this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint);
} }
/** /**
@@ -60,7 +67,7 @@ export class BitstreamFormatDataService extends DataService<BitstreamFormat> {
*/ */
public getUpdateEndpoint(formatId: string): Observable<string> { public getUpdateEndpoint(formatId: string): Observable<string> {
return this.getBrowseEndpoint().pipe( return this.getBrowseEndpoint().pipe(
map((endpoint: string) => this.getIDHref(endpoint, formatId)) map((endpoint: string) => this.getIDHref(endpoint, formatId)),
); );
} }
@@ -147,4 +154,47 @@ export class BitstreamFormatDataService extends DataService<BitstreamFormat> {
findByBitstream(bitstream: Bitstream): Observable<RemoteData<BitstreamFormat>> { findByBitstream(bitstream: Bitstream): Observable<RemoteData<BitstreamFormat>> {
return this.findByHref(bitstream._links.format.href); return this.findByHref(bitstream._links.format.href);
} }
/**
* Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded
* info should be added to the objects
*
* @param options Find list options object
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
* @return {Observable<RemoteData<PaginatedList<T>>>}
* Return an observable that emits object list
*/
findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<BitstreamFormat>[]): Observable<RemoteData<PaginatedList<BitstreamFormat>>> {
return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Delete an existing object on the server
* @param objectId The id of the object to be removed
* @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual
* metadata should be saved as real metadata
* @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode,
* errorMessage, timeCompleted, etc
*/
delete(objectId: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> {
return this.deleteData.delete(objectId, copyVirtualMetadata);
}
/**
* Delete an existing object on the server
* @param href The self link of the object to be removed
* @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual
* metadata should be saved as real metadata
* @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode,
* errorMessage, timeCompleted, etc
* Only emits once all request related to the DSO has been invalidated.
*/
deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> {
return this.deleteData.deleteByHref(href, copyVirtualMetadata);
}
} }

View File

@@ -13,6 +13,7 @@ import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.util
import { createPaginatedList } from '../../shared/testing/utils.test'; import { createPaginatedList } from '../../shared/testing/utils.test';
import { Bundle } from '../shared/bundle.model'; import { Bundle } from '../shared/bundle.model';
import { CoreState } from '../core-state.model'; import { CoreState } from '../core-state.model';
import { testPatchDataImplementation } from './base/patch-data.spec';
class DummyChangeAnalyzer implements ChangeAnalyzer<Item> { class DummyChangeAnalyzer implements ChangeAnalyzer<Item> {
diff(object1: Item, object2: Item): Operation[] { diff(object1: Item, object2: Item): Operation[] {
@@ -64,9 +65,6 @@ describe('BundleDataService', () => {
store, store,
objectCache, objectCache,
halService, halService,
notificationsService,
http,
comparator,
); );
} }
@@ -74,14 +72,20 @@ describe('BundleDataService', () => {
service = initTestService(); service = initTestService();
}); });
describe('composition', () => {
const initService = () => new BundleDataService(null, null, null, null, null);
testPatchDataImplementation(initService);
});
describe('findAllByItem', () => { describe('findAllByItem', () => {
beforeEach(() => { beforeEach(() => {
spyOn(service, 'findAllByHref'); spyOn(service, 'findListByHref');
service.findAllByItem(item); service.findAllByItem(item);
}); });
it('should call findAllByHref with the item\'s bundles link', () => { it('should call findListByHref with the item\'s bundles link', () => {
expect(service.findAllByHref).toHaveBeenCalledWith(bundleLink, undefined, true, true); expect(service.findListByHref).toHaveBeenCalledWith(bundleLink, undefined, true, true);
}); });
}); });

View File

@@ -1,20 +1,14 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map, switchMap, take } from 'rxjs/operators'; import { map, switchMap, take } from 'rxjs/operators';
import { hasValue } from '../../shared/empty.util'; import { hasValue } from '../../shared/empty.util';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { dataService } from '../cache/builders/build-decorators';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { Bundle } from '../shared/bundle.model'; import { Bundle } from '../shared/bundle.model';
import { BUNDLE } from '../shared/bundle.resource-type'; import { BUNDLE } from '../shared/bundle.resource-type';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { Item } from '../shared/item.model'; import { Item } from '../shared/item.model';
import { DataService } from './data.service';
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
import { PaginatedList } from './paginated-list.model'; import { PaginatedList } from './paginated-list.model';
import { RemoteData } from './remote-data'; import { RemoteData } from './remote-data';
import { GetRequest } from './request.models'; import { GetRequest } from './request.models';
@@ -22,30 +16,36 @@ import { RequestService } from './request.service';
import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model';
import { Bitstream } from '../shared/bitstream.model'; import { Bitstream } from '../shared/bitstream.model';
import { RequestEntryState } from './request-entry-state.model'; import { RequestEntryState } from './request-entry-state.model';
import { CoreState } from '../core-state.model';
import { FindListOptions } from './find-list-options.model'; import { FindListOptions } from './find-list-options.model';
import { IdentifiableDataService } from './base/identifiable-data.service';
import { PatchData, PatchDataImpl } from './base/patch-data';
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
import { RestRequestMethod } from './rest-request-method';
import { Operation } from 'fast-json-patch';
import { dataService } from './base/data-service.decorator';
/** /**
* A service to retrieve {@link Bundle}s from the REST API * A service to retrieve {@link Bundle}s from the REST API
*/ */
@Injectable( @Injectable(
{providedIn: 'root'} { providedIn: 'root' },
) )
@dataService(BUNDLE) @dataService(BUNDLE)
export class BundleDataService extends DataService<Bundle> { export class BundleDataService extends IdentifiableDataService<Bundle> implements PatchData<Bundle> {
protected linkPath = 'bundles'; private bitstreamsEndpoint = 'bitstreams';
protected bitstreamsEndpoint = 'bitstreams';
private patchData: PatchDataImpl<Bundle>;
constructor( constructor(
protected requestService: RequestService, protected requestService: RequestService,
protected rdbService: RemoteDataBuildService, protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
protected halService: HALEndpointService, protected halService: HALEndpointService,
protected notificationsService: NotificationsService, protected comparator: DSOChangeAnalyzer<Bundle>,
protected http: HttpClient, ) {
protected comparator: DefaultChangeAnalyzer<Bundle>) { super('bundles', requestService, rdbService, objectCache, halService);
super();
this.patchData = new PatchDataImpl<Bundle>(this.linkPath, requestService, rdbService, objectCache, halService, comparator, this.responseMsToLive, this.constructIdEndpoint);
} }
/** /**
@@ -61,7 +61,7 @@ export class BundleDataService extends DataService<Bundle> {
* {@link HALLink}s should be automatically resolved * {@link HALLink}s should be automatically resolved
*/ */
findAllByItem(item: Item, options?: FindListOptions, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<Bundle>[]): Observable<RemoteData<PaginatedList<Bundle>>> { findAllByItem(item: Item, options?: FindListOptions, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<Bundle>[]): Observable<RemoteData<PaginatedList<Bundle>>> {
return this.findAllByHref(item._links.bundles.href, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); return this.findListByHref(item._links.bundles.href, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
} }
/** /**
@@ -133,7 +133,7 @@ export class BundleDataService extends DataService<Bundle> {
const hrefObs = this.getBitstreamsEndpoint(bundleId, searchOptions); const hrefObs = this.getBitstreamsEndpoint(bundleId, searchOptions);
hrefObs.pipe( hrefObs.pipe(
take(1) take(1),
).subscribe((href) => { ).subscribe((href) => {
const request = new GetRequest(this.requestService.generateRequestId(), href); const request = new GetRequest(this.requestService.generateRequestId(), href);
this.requestService.send(request, true); this.requestService.send(request, true);
@@ -141,4 +141,38 @@ export class BundleDataService extends DataService<Bundle> {
return this.rdbService.buildList<Bitstream>(hrefObs, ...linksToFollow); return this.rdbService.buildList<Bitstream>(hrefObs, ...linksToFollow);
} }
/**
* Commit current object changes to the server
* @param method The RestRequestMethod for which de server sync buffer should be committed
*/
public commitUpdates(method?: RestRequestMethod): void {
this.patchData.commitUpdates(method);
}
/**
* Send a patch request for a specified object
* @param {T} object The object to send a patch request for
* @param {Operation[]} operations The patch operations to be performed
*/
public patch(object: Bundle, operations: Operation[]): Observable<RemoteData<Bundle>> {
return this.patchData.patch(object, operations);
}
/**
* Add a new patch to the object cache
* The patch is derived from the differences between the given object and its version in the object cache
* @param {DSpaceObject} object The given object
*/
public update(object: Bundle): Observable<RemoteData<Bundle>> {
return this.patchData.update(object);
}
/**
* Return a list of operations representing the difference between an object and its latest value in the cache.
* @param object the object to resolve to a list of patch operations
*/
public createPatchFromCache(object: Bundle): Observable<Operation[]> {
return this.patchData.createPatchFromCache(object);
}
} }

View File

@@ -13,16 +13,17 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv
import { Collection } from '../shared/collection.model'; import { Collection } from '../shared/collection.model';
import { PageInfo } from '../shared/page-info.model'; import { PageInfo } from '../shared/page-info.model';
import { buildPaginatedList } from './paginated-list.model'; import { buildPaginatedList } from './paginated-list.model';
import { import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
createFailedRemoteDataObject$,
createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$
} from '../../shared/remote-data.utils';
import { cold, getTestScheduler, hot } from 'jasmine-marbles'; import { cold, getTestScheduler, hot } from 'jasmine-marbles';
import { TestScheduler } from 'rxjs/testing'; import { TestScheduler } from 'rxjs/testing';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { RemoteData } from './remote-data'; import { RemoteData } from './remote-data';
import { hasNoValue } from '../../shared/empty.util'; import { hasNoValue } from '../../shared/empty.util';
import { testCreateDataImplementation } from './base/create-data.spec';
import { testFindAllDataImplementation } from './base/find-all-data.spec';
import { testSearchDataImplementation } from './base/search-data.spec';
import { testPatchDataImplementation } from './base/patch-data.spec';
import { testDeleteDataImplementation } from './base/delete-data.spec';
const url = 'fake-url'; const url = 'fake-url';
const collectionId = 'fake-collection-id'; const collectionId = 'fake-collection-id';
@@ -75,6 +76,16 @@ describe('CollectionDataService', () => {
const paginatedList = buildPaginatedList(pageInfo, array); const paginatedList = buildPaginatedList(pageInfo, array);
const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList);
describe('composition', () => {
const initService = () => new CollectionDataService(null, null, null, null, null, null, null, null, null);
testCreateDataImplementation(initService);
testFindAllDataImplementation(initService);
testSearchDataImplementation(initService);
testPatchDataImplementation(initService);
testDeleteDataImplementation(initService);
});
describe('when the requests are successful', () => { describe('when the requests are successful', () => {
beforeEach(() => { beforeEach(() => {
createService(); createService();
@@ -201,7 +212,7 @@ describe('CollectionDataService', () => {
notificationsService = new NotificationsServiceStub(); notificationsService = new NotificationsServiceStub();
translate = getMockTranslateService(); translate = getMockTranslateService();
service = new CollectionDataService(requestService, rdbService, null, null, objectCache, halService, notificationsService, null, null, null, translate); service = new CollectionDataService(requestService, rdbService, objectCache, halService, null, notificationsService, null, null, translate);
} }
}); });

View File

@@ -1,6 +1,5 @@
import { HttpClient, HttpHeaders } from '@angular/common/http'; import { HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { filter, map, switchMap, take } from 'rxjs/operators'; import { filter, map, switchMap, take } from 'rxjs/operators';
@@ -9,7 +8,6 @@ import { NotificationOptions } from '../../shared/notifications/models/notificat
import { INotification } from '../../shared/notifications/models/notification.model'; import { INotification } from '../../shared/notifications/models/notification.model';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { dataService } from '../cache/builders/build-decorators';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { RequestParam } from '../cache/models/request-param.model'; import { RequestParam } from '../cache/models/request-param.model';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
@@ -33,30 +31,28 @@ import {
import { RequestService } from './request.service'; import { RequestService } from './request.service';
import { BitstreamDataService } from './bitstream-data.service'; import { BitstreamDataService } from './bitstream-data.service';
import { RestRequest } from './rest-request.model'; import { RestRequest } from './rest-request.model';
import { CoreState } from '../core-state.model';
import { FindListOptions } from './find-list-options.model'; import { FindListOptions } from './find-list-options.model';
import { Community } from '../shared/community.model';
import { dataService } from './base/data-service.decorator';
@Injectable() @Injectable()
@dataService(COLLECTION) @dataService(COLLECTION)
export class CollectionDataService extends ComColDataService<Collection> { export class CollectionDataService extends ComColDataService<Collection> {
protected linkPath = 'collections';
protected errorTitle = 'collection.source.update.notifications.error.title'; protected errorTitle = 'collection.source.update.notifications.error.title';
protected contentSourceError = 'collection.source.update.notifications.error.content'; protected contentSourceError = 'collection.source.update.notifications.error.content';
constructor( constructor(
protected requestService: RequestService, protected requestService: RequestService,
protected rdbService: RemoteDataBuildService, protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected cds: CommunityDataService,
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
protected halService: HALEndpointService, protected halService: HALEndpointService,
protected comparator: DSOChangeAnalyzer<Community>,
protected notificationsService: NotificationsService, protected notificationsService: NotificationsService,
protected http: HttpClient,
protected bitstreamDataService: BitstreamDataService, protected bitstreamDataService: BitstreamDataService,
protected comparator: DSOChangeAnalyzer<Collection>, protected communityDataService: CommunityDataService,
protected translate: TranslateService protected translate: TranslateService,
) { ) {
super(); super('collections', requestService, rdbService, objectCache, halService, comparator, notificationsService, bitstreamDataService);
} }
/** /**
@@ -284,15 +280,15 @@ export class CollectionDataService extends ComColDataService<Collection> {
* @param findListOptions Pagination and search options. * @param findListOptions Pagination and search options.
*/ */
findMappedCollectionsFor(item: Item, findListOptions?: FindListOptions): Observable<RemoteData<PaginatedList<Collection>>> { findMappedCollectionsFor(item: Item, findListOptions?: FindListOptions): Observable<RemoteData<PaginatedList<Collection>>> {
return this.findAllByHref(item._links.mappedCollections.href, findListOptions); return this.findListByHref(item._links.mappedCollections.href, findListOptions);
} }
protected getScopeCommunityHref(options: FindListOptions) { protected getScopeCommunityHref(options: FindListOptions) {
return this.cds.getEndpoint().pipe( return this.communityDataService.getEndpoint().pipe(
map((endpoint: string) => this.cds.getIDHref(endpoint, options.scopeID)), map((endpoint: string) => this.communityDataService.getIDHref(endpoint, options.scopeID)),
filter((href: string) => isNotEmpty(href)), filter((href: string) => isNotEmpty(href)),
take(1) take(1),
); );
} }
} }

View File

@@ -13,16 +13,16 @@ import { ComColDataService } from './comcol-data.service';
import { CommunityDataService } from './community-data.service'; import { CommunityDataService } from './community-data.service';
import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
import { import { createFailedRemoteDataObject, createFailedRemoteDataObject$, createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
createFailedRemoteDataObject$,
createSuccessfulRemoteDataObject$,
createFailedRemoteDataObject,
createSuccessfulRemoteDataObject
} from '../../shared/remote-data.utils';
import { BitstreamDataService } from './bitstream-data.service'; import { BitstreamDataService } from './bitstream-data.service';
import { CoreState } from '../core-state.model'; import { CoreState } from '../core-state.model';
import { FindListOptions } from './find-list-options.model'; import { FindListOptions } from './find-list-options.model';
import { Bitstream } from '../shared/bitstream.model'; import { Bitstream } from '../shared/bitstream.model';
import { testCreateDataImplementation } from './base/create-data.spec';
import { testFindAllDataImplementation } from './base/find-all-data.spec';
import { testSearchDataImplementation } from './base/search-data.spec';
import { testPatchDataImplementation } from './base/patch-data.spec';
import { testDeleteDataImplementation } from './base/delete-data.spec';
const LINK_NAME = 'test'; const LINK_NAME = 'test';
@@ -47,7 +47,7 @@ class TestService extends ComColDataService<any> {
protected comparator: DSOChangeAnalyzer<Community>, protected comparator: DSOChangeAnalyzer<Community>,
protected linkPath: string protected linkPath: string
) { ) {
super(); super('something', requestService, rdbService, objectCache, halService, comparator, notificationsService, bitstreamDataService);
} }
protected getFindByParentHref(parentUUID: string): Observable<string> { protected getFindByParentHref(parentUUID: string): Observable<string> {
@@ -154,6 +154,15 @@ describe('ComColDataService', () => {
service = initTestService(); service = initTestService();
}); });
describe('composition', () => {
const initService = () => new TestService(null, null, null, null, null, null, null, null, null, null, null);
testCreateDataImplementation(initService);
testFindAllDataImplementation(initService);
testSearchDataImplementation(initService);
testPatchDataImplementation(initService);
testDeleteDataImplementation(initService);
});
describe('getBrowseEndpoint', () => { describe('getBrowseEndpoint', () => {
it(`should call createAndSendGetRequest with the scope Community's self link`, () => { it(`should call createAndSendGetRequest with the scope Community's self link`, () => {
testScheduler.run(({ cold, flush, expectObservable }) => { testScheduler.run(({ cold, flush, expectObservable }) => {

View File

@@ -4,7 +4,6 @@ import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { Community } from '../shared/community.model'; import { Community } from '../shared/community.model';
import { HALLink } from '../shared/hal-link.model'; import { HALLink } from '../shared/hal-link.model';
import { DataService } from './data.service';
import { PaginatedList } from './paginated-list.model'; import { PaginatedList } from './paginated-list.model';
import { RemoteData } from './remote-data'; import { RemoteData } from './remote-data';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
@@ -17,11 +16,45 @@ import { createFailedRemoteDataObject$ } from '../../shared/remote-data.utils';
import { URLCombiner } from '../url-combiner/url-combiner'; import { URLCombiner } from '../url-combiner/url-combiner';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { FindListOptions } from './find-list-options.model'; import { FindListOptions } from './find-list-options.model';
import { IdentifiableDataService } from './base/identifiable-data.service';
import { PatchData, PatchDataImpl } from './base/patch-data';
import { DeleteData, DeleteDataImpl } from './base/delete-data';
import { FindAllData, FindAllDataImpl } from './base/find-all-data';
import { SearchData, SearchDataImpl } from './base/search-data';
import { RestRequestMethod } from './rest-request-method';
import { CreateData, CreateDataImpl } from './base/create-data';
import { RequestParam } from '../cache/models/request-param.model';
import { RequestService } from './request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
import { Operation } from 'fast-json-patch';
export abstract class ComColDataService<T extends Community | Collection> extends DataService<T> { export abstract class ComColDataService<T extends Community | Collection> extends IdentifiableDataService<T> implements CreateData<T>, FindAllData<T>, SearchData<T>, PatchData<T>, DeleteData<T> {
protected abstract objectCache: ObjectCacheService; private createData: CreateData<T>;
protected abstract halService: HALEndpointService; private findAllData: FindAllData<T>;
protected abstract bitstreamDataService: BitstreamDataService; private searchData: SearchData<T>;
private patchData: PatchData<T>;
private deleteData: DeleteData<T>;
protected constructor(
protected linkPath: string,
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected comparator: DSOChangeAnalyzer<T>,
protected notificationsService: NotificationsService,
protected bitstreamDataService: BitstreamDataService,
) {
super(linkPath, requestService, rdbService, objectCache, halService);
this.createData = new CreateDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive);
this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
this.searchData = new SearchDataImpl<T>(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
this.patchData = new PatchDataImpl<T>(this.linkPath, requestService, rdbService, objectCache, halService, comparator, this.responseMsToLive, this.constructIdEndpoint);
this.deleteData = new DeleteDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, notificationsService, this.responseMsToLive, this.constructIdEndpoint);
}
/** /**
* Get the scoped endpoint URL by fetching the object with * Get the scoped endpoint URL by fetching the object with
@@ -66,7 +99,7 @@ export abstract class ComColDataService<T extends Community | Collection> extend
const href$ = this.getFindByParentHref(parentUUID).pipe( const href$ = this.getFindByParentHref(parentUUID).pipe(
map((href: string) => this.buildHrefFromFindOptions(href, options)) map((href: string) => this.buildHrefFromFindOptions(href, options))
); );
return this.findAllByHref(href$, options, true, true, ...linksToFollow); return this.findListByHref(href$, options, true, true, ...linksToFollow);
} }
/** /**
@@ -129,4 +162,110 @@ export abstract class ComColDataService<T extends Community | Collection> extend
const parentCommunity = dso._links.parentCommunity; const parentCommunity = dso._links.parentCommunity;
return isNotEmpty(parentCommunity) ? parentCommunity.href : null; return isNotEmpty(parentCommunity) ? parentCommunity.href : null;
} }
/**
* Create a new object on the server, and store the response in the object cache
*
* @param object The object to create
* @param params Array with additional params to combine with query string
*/
create(object: T, ...params: RequestParam[]): Observable<RemoteData<T>> {
return this.createData.create(object, ...params);
}
/**
* Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded
* info should be added to the objects
*
* @param options Find list options object
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
* @return {Observable<RemoteData<PaginatedList<T>>>}
* Return an observable that emits object list
*/
public findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<PaginatedList<T>>> {
return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Make a new FindListRequest with given search method
*
* @param searchMethod The search method for the object
* @param options The [[FindListOptions]] object
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
* @return {Observable<RemoteData<PaginatedList<T>>}
* Return an observable that emits response from the server
*/
public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<PaginatedList<T>>> {
return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Commit current object changes to the server
* @param method The RestRequestMethod for which de server sync buffer should be committed
*/
public commitUpdates(method?: RestRequestMethod): void {
this.patchData.commitUpdates(method);
}
/**
* Send a patch request for a specified object
* @param {T} object The object to send a patch request for
* @param {Operation[]} operations The patch operations to be performed
*/
public patch(object: T, operations: []): Observable<RemoteData<T>> {
return this.patchData.patch(object, operations);
}
/**
* Add a new patch to the object cache
* The patch is derived from the differences between the given object and its version in the object cache
* @param {DSpaceObject} object The given object
*/
public update(object: T): Observable<RemoteData<T>> {
return this.patchData.update(object);
}
/**
* Return a list of operations representing the difference between an object and its latest value in the cache.
* @param object the object to resolve to a list of patch operations
*/
public createPatchFromCache(object: T): Observable<Operation[]> {
return this.patchData.createPatchFromCache(object);
}
/**
* Delete an existing object on the server
* @param objectId The id of the object to be removed
* @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual
* metadata should be saved as real metadata
* @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode,
* errorMessage, timeCompleted, etc
*/
public delete(objectId: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> {
return this.deleteData.delete(objectId, copyVirtualMetadata);
}
/**
* Delete an existing object on the server
* @param href The self link of the object to be removed
* @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual
* metadata should be saved as real metadata
* @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode,
* errorMessage, timeCompleted, etc
* Only emits once all request related to the DSO has been invalidated.
*/
public deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> {
return this.deleteData.deleteByHref(href, copyVirtualMetadata);
}
} }

View File

@@ -1,11 +1,8 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { filter, map, switchMap, take } from 'rxjs/operators'; import { filter, map, switchMap, take } from 'rxjs/operators';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
import { dataService } from '../cache/builders/build-decorators';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { Community } from '../shared/community.model'; import { Community } from '../shared/community.model';
@@ -19,36 +16,36 @@ import { RequestService } from './request.service';
import { BitstreamDataService } from './bitstream-data.service'; import { BitstreamDataService } from './bitstream-data.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { isNotEmpty } from '../../shared/empty.util'; import { isNotEmpty } from '../../shared/empty.util';
import { CoreState } from '../core-state.model';
import { FindListOptions } from './find-list-options.model'; import { FindListOptions } from './find-list-options.model';
import { dataService } from './base/data-service.decorator';
@Injectable() @Injectable()
@dataService(COMMUNITY) @dataService(COMMUNITY)
export class CommunityDataService extends ComColDataService<Community> { export class CommunityDataService extends ComColDataService<Community> {
protected linkPath = 'communities';
protected topLinkPath = 'search/top'; protected topLinkPath = 'search/top';
constructor( constructor(
protected requestService: RequestService, protected requestService: RequestService,
protected rdbService: RemoteDataBuildService, protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
protected halService: HALEndpointService, protected halService: HALEndpointService,
protected comparator: DSOChangeAnalyzer<Community>,
protected notificationsService: NotificationsService, protected notificationsService: NotificationsService,
protected bitstreamDataService: BitstreamDataService, protected bitstreamDataService: BitstreamDataService,
protected http: HttpClient,
protected comparator: DSOChangeAnalyzer<Community>
) { ) {
super(); super('communities', requestService, rdbService, objectCache, halService, comparator, notificationsService, bitstreamDataService);
} }
// this method is overridden in order to make it public
getEndpoint() { getEndpoint() {
return this.halService.getEndpoint(this.linkPath); return this.halService.getEndpoint(this.linkPath);
} }
findTop(options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig<Community>[]): Observable<RemoteData<PaginatedList<Community>>> { findTop(options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig<Community>[]): Observable<RemoteData<PaginatedList<Community>>> {
const hrefObs = this.getFindAllHref(options, this.topLinkPath); return this.getEndpoint().pipe(
return this.findAllByHref(hrefObs, undefined, true, true, ...linksToFollow); map(href => `${href}/search/top`),
switchMap(href => this.findListByHref(href, options, true, true, ...linksToFollow))
);
} }
protected getFindByParentHref(parentUUID: string): Observable<string> { protected getFindByParentHref(parentUUID: string): Observable<string> {

View File

@@ -5,8 +5,6 @@ import { HALEndpointService } from '../shared/hal-endpoint.service';
import { GetRequest } from './request.models'; import { GetRequest } from './request.models';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
import { ConfigurationDataService } from './configuration-data.service'; import { ConfigurationDataService } from './configuration-data.service';
import { ConfigurationProperty } from '../shared/configuration-property.model'; import { ConfigurationProperty } from '../shared/configuration-property.model';
@@ -44,18 +42,12 @@ describe('ConfigurationDataService', () => {
}) })
}); });
objectCache = {} as ObjectCacheService; objectCache = {} as ObjectCacheService;
const notificationsService = {} as NotificationsService;
const http = {} as HttpClient;
const comparator = {} as any;
service = new ConfigurationDataService( service = new ConfigurationDataService(
requestService, requestService,
rdbService, rdbService,
objectCache, objectCache,
halService, halService,
notificationsService,
http,
comparator
); );
}); });

View File

@@ -1,55 +1,30 @@
/* eslint-disable max-classes-per-file */ /* eslint-disable max-classes-per-file */
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { dataService } from '../cache/builders/build-decorators';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { DataService } from './data.service';
import { RemoteData } from './remote-data'; import { RemoteData } from './remote-data';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
import { ConfigurationProperty } from '../shared/configuration-property.model'; import { ConfigurationProperty } from '../shared/configuration-property.model';
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
import { CONFIG_PROPERTY } from '../shared/config-property.resource-type'; import { CONFIG_PROPERTY } from '../shared/config-property.resource-type';
import { CoreState } from '../core-state.model'; import { IdentifiableDataService } from './base/identifiable-data.service';
import { dataService } from './base/data-service.decorator';
class DataServiceImpl extends DataService<ConfigurationProperty> {
protected linkPath = 'properties';
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DefaultChangeAnalyzer<ConfigurationProperty>) {
super();
}
}
@Injectable() @Injectable()
@dataService(CONFIG_PROPERTY) @dataService(CONFIG_PROPERTY)
/** /**
* Data Service responsible for retrieving Configuration properties * Data Service responsible for retrieving Configuration properties
*/ */
export class ConfigurationDataService { export class ConfigurationDataService extends IdentifiableDataService<ConfigurationProperty> {
protected linkPath = 'properties';
private dataService: DataServiceImpl;
constructor( constructor(
protected requestService: RequestService, protected requestService: RequestService,
protected rdbService: RemoteDataBuildService, protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
protected halService: HALEndpointService, protected halService: HALEndpointService,
protected notificationsService: NotificationsService, ) {
protected http: HttpClient, super('properties', requestService, rdbService, objectCache, halService);
protected comparator: DefaultChangeAnalyzer<ConfigurationProperty>) {
this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator);
} }
/** /**
@@ -57,6 +32,6 @@ export class ConfigurationDataService {
* @param name * @param name
*/ */
findByPropertyName(name: string): Observable<RemoteData<ConfigurationProperty>> { findByPropertyName(name: string): Observable<RemoteData<ConfigurationProperty>> {
return this.dataService.findById(name); return this.findById(name);
} }
} }

View File

@@ -1,693 +0,0 @@
import { HttpClient } from '@angular/common/http';
import { Store } from '@ngrx/store';
import { Operation } from 'fast-json-patch';
import { AsyncSubject, combineLatest, from as observableFrom, Observable, of as observableOf } from 'rxjs';
import {
distinctUntilChanged,
filter,
find,
map,
mergeMap,
skipWhile,
switchMap,
take,
takeWhile,
tap,
toArray
} from 'rxjs/operators';
import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
import { NotificationOptions } from '../../shared/notifications/models/notification-options.model';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { getClassForType } from '../cache/builders/build-decorators';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { RequestParam } from '../cache/models/request-param.model';
import { ObjectCacheEntry } from '../cache/object-cache.reducer';
import { ObjectCacheService } from '../cache/object-cache.service';
import { DSpaceSerializer } from '../dspace-rest/dspace.serializer';
import { DSpaceObject } from '../shared/dspace-object.model';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { getFirstCompletedRemoteData, getFirstSucceededRemoteData, getRemoteDataPayload } from '../shared/operators';
import { URLCombiner } from '../url-combiner/url-combiner';
import { ChangeAnalyzer } from './change-analyzer';
import { PaginatedList } from './paginated-list.model';
import { RemoteData } from './remote-data';
import { CreateRequest, DeleteRequest, GetRequest, PatchRequest, PutRequest } from './request.models';
import { RequestService } from './request.service';
import { RestRequestMethod } from './rest-request-method';
import { UpdateDataService } from './update-data.service';
import { GenericConstructor } from '../shared/generic-constructor';
import { NoContent } from '../shared/NoContent.model';
import { CacheableObject } from '../cache/cacheable-object.model';
import { CoreState } from '../core-state.model';
import { FindListOptions } from './find-list-options.model';
export abstract class DataService<T extends CacheableObject> implements UpdateDataService<T> {
protected abstract requestService: RequestService;
protected abstract rdbService: RemoteDataBuildService;
protected abstract store: Store<CoreState>;
protected abstract linkPath: string;
protected abstract halService: HALEndpointService;
protected abstract objectCache: ObjectCacheService;
protected abstract notificationsService: NotificationsService;
protected abstract http: HttpClient;
protected abstract comparator: ChangeAnalyzer<T>;
/**
* Allows subclasses to reset the response cache time.
*/
protected responseMsToLive: number;
/**
* Get the endpoint for browsing
* @param options The [[FindListOptions]] object
* @param linkPath The link path for the object
* @returns {Observable<string>}
*/
getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable<string> {
return this.getEndpoint();
}
/**
* Get the base endpoint for all requests
*/
protected getEndpoint(): Observable<string> {
return this.halService.getEndpoint(this.linkPath);
}
/**
* Create the HREF with given options object
*
* @param options The [[FindListOptions]] object
* @param linkPath The link path for the object
* @return {Observable<string>}
* Return an observable that emits created HREF
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
public getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: FollowLinkConfig<T>[]): Observable<string> {
let endpoint$: Observable<string>;
const args = [];
endpoint$ = this.getBrowseEndpoint(options).pipe(
filter((href: string) => isNotEmpty(href)),
map((href: string) => isNotEmpty(linkPath) ? `${href}/${linkPath}` : href),
distinctUntilChanged()
);
return endpoint$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow)));
}
/**
* Create the HREF for a specific object's search method with given options object
*
* @param searchMethod The search method for the object
* @param options The [[FindListOptions]] object
* @return {Observable<string>}
* Return an observable that emits created HREF
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
public getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: FollowLinkConfig<T>[]): Observable<string> {
let result$: Observable<string>;
const args = [];
result$ = this.getSearchEndpoint(searchMethod);
return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow)));
}
/**
* Turn an options object into a query string and combine it with the given HREF
*
* @param href The HREF to which the query string should be appended
* @param options The [[FindListOptions]] object
* @param extraArgs Array with additional params to combine with query string
* @return {Observable<string>}
* Return an observable that emits created HREF
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
public buildHrefFromFindOptions(href: string, options: FindListOptions, extraArgs: string[] = [], ...linksToFollow: FollowLinkConfig<T>[]): string {
let args = [...extraArgs];
if (hasValue(options.currentPage) && typeof options.currentPage === 'number') {
/* TODO: this is a temporary fix for the pagination start index (0 or 1) discrepancy between the rest and the frontend respectively */
args = this.addHrefArg(href, args, `page=${options.currentPage - 1}`);
}
if (hasValue(options.elementsPerPage)) {
args = this.addHrefArg(href, args, `size=${options.elementsPerPage}`);
}
if (hasValue(options.sort)) {
args = this.addHrefArg(href, args, `sort=${options.sort.field},${options.sort.direction}`);
}
if (hasValue(options.startsWith)) {
args = this.addHrefArg(href, args, `startsWith=${options.startsWith}`);
}
if (hasValue(options.searchParams)) {
options.searchParams.forEach((param: RequestParam) => {
args = this.addHrefArg(href, args, `${param.fieldName}=${param.fieldValue}`);
});
}
args = this.addEmbedParams(href, args, ...linksToFollow);
if (isNotEmpty(args)) {
return new URLCombiner(href, `?${args.join('&')}`).toString();
} else {
return href;
}
}
/**
* Turn an array of RequestParam into a query string and combine it with the given HREF
*
* @param href The HREF to which the query string should be appended
* @param params Array with additional params to combine with query string
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*
* @return {Observable<string>}
* Return an observable that emits created HREF
*/
buildHrefWithParams(href: string, params: RequestParam[], ...linksToFollow: FollowLinkConfig<T>[]): string {
let args = [];
if (hasValue(params)) {
params.forEach((param: RequestParam) => {
args = this.addHrefArg(href, args, `${param.fieldName}=${param.fieldValue}`);
});
}
args = this.addEmbedParams(href, args, ...linksToFollow);
if (isNotEmpty(args)) {
return new URLCombiner(href, `?${args.join('&')}`).toString();
} else {
return href;
}
}
/**
* Adds the embed options to the link for the request
* @param href The href the params are to be added to
* @param args params for the query string
* @param linksToFollow links we want to embed in query string if shouldEmbed is true
*/
protected addEmbedParams(href: string, args: string[], ...linksToFollow: FollowLinkConfig<T>[]) {
linksToFollow.forEach((linkToFollow: FollowLinkConfig<T>) => {
if (hasValue(linkToFollow) && linkToFollow.shouldEmbed) {
const embedString = 'embed=' + String(linkToFollow.name);
// Add the embeds size if given in the FollowLinkConfig.FindListOptions
if (hasValue(linkToFollow.findListOptions) && hasValue(linkToFollow.findListOptions.elementsPerPage)) {
args = this.addHrefArg(href, args,
'embed.size=' + String(linkToFollow.name) + '=' + linkToFollow.findListOptions.elementsPerPage);
}
// Adds the nested embeds and their size if given
if (isNotEmpty(linkToFollow.linksToFollow)) {
args = this.addNestedEmbeds(embedString, href, args, ...linkToFollow.linksToFollow);
} else {
args = this.addHrefArg(href, args, embedString);
}
}
});
return args;
}
/**
* Add a new argument to the list of arguments, only if it doesn't already exist in the given href,
* or the current list of arguments
*
* @param href The href the arguments are to be added to
* @param currentArgs The current list of arguments
* @param newArg The new argument to add
* @return The next list of arguments, with newArg included if it wasn't already.
* Note this function will not modify any of the input params.
*/
protected addHrefArg(href: string, currentArgs: string[], newArg: string): string[] {
if (href.includes(newArg) || currentArgs.includes(newArg)) {
return [...currentArgs];
} else {
return [...currentArgs, newArg];
}
}
/**
* Add the nested followLinks to the embed param, separated by a /, and their sizes, recursively
* @param embedString embedString so far (recursive)
* @param href The href the params are to be added to
* @param args params for the query string
* @param linksToFollow links we want to embed in query string if shouldEmbed is true
*/
protected addNestedEmbeds(embedString: string, href: string, args: string[], ...linksToFollow: FollowLinkConfig<T>[]): string[] {
let nestEmbed = embedString;
linksToFollow.forEach((linkToFollow: FollowLinkConfig<T>) => {
if (hasValue(linkToFollow) && linkToFollow.shouldEmbed) {
nestEmbed = nestEmbed + '/' + String(linkToFollow.name);
// Add the nested embeds size if given in the FollowLinkConfig.FindListOptions
if (hasValue(linkToFollow.findListOptions) && hasValue(linkToFollow.findListOptions.elementsPerPage)) {
const nestedEmbedSize = 'embed.size=' + nestEmbed.split('=')[1] + '=' + linkToFollow.findListOptions.elementsPerPage;
args = this.addHrefArg(href, args, nestedEmbedSize);
}
if (hasValue(linkToFollow.linksToFollow) && isNotEmpty(linkToFollow.linksToFollow)) {
args = this.addNestedEmbeds(nestEmbed, href, args, ...linkToFollow.linksToFollow);
} else {
args = this.addHrefArg(href, args, nestEmbed);
}
}
});
return args;
}
/**
* Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded
* info should be added to the objects
*
* @param options Find list options object
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
* @return {Observable<RemoteData<PaginatedList<T>>>}
* Return an observable that emits object list
*/
findAll(options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<PaginatedList<T>>> {
return this.findAllByHref(this.getFindAllHref(options), options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Create the HREF for a specific object based on its identifier; with possible embed query params based on linksToFollow
* @param endpoint The base endpoint for the type of object
* @param resourceID The identifier for the object
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
getIDHref(endpoint, resourceID, ...linksToFollow: FollowLinkConfig<T>[]): string {
return this.buildHrefFromFindOptions(endpoint + '/' + resourceID, {}, [], ...linksToFollow);
}
/**
* Create an observable for the HREF of a specific object based on its identifier
* @param resourceID The identifier for the object
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
getIDHrefObs(resourceID: string, ...linksToFollow: FollowLinkConfig<T>[]): Observable<string> {
return this.getEndpoint().pipe(
map((endpoint: string) => this.getIDHref(endpoint, resourceID, ...linksToFollow)));
}
/**
* Returns an observable of {@link RemoteData} of an object, based on its ID, with a list of
* {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object
* @param id ID of object we want to retrieve
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
*/
findById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<T>> {
const href$ = this.getIDHrefObs(encodeURIComponent(id), ...linksToFollow);
return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* An operator that will call the given function if the incoming RemoteData is stale and
* shouldReRequest is true
*
* @param shouldReRequest Whether or not to call the re-request function if the RemoteData is stale
* @param requestFn The function to call if the RemoteData is stale and shouldReRequest is
* true
*/
protected reRequestStaleRemoteData<O>(shouldReRequest: boolean, requestFn: () => Observable<RemoteData<O>>) {
return (source: Observable<RemoteData<O>>): Observable<RemoteData<O>> => {
if (shouldReRequest === true) {
return source.pipe(
tap((remoteData: RemoteData<O>) => {
if (hasValue(remoteData) && remoteData.isStale) {
requestFn();
}
})
);
} else {
return source;
}
};
}
/**
* Returns an observable of {@link RemoteData} of an object, based on an href, with a list of
* {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object
* @param href$ The url of object we want to retrieve. Can be a string or
* an Observable<string>
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
*/
findByHref(href$: string | Observable<string>, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<T>> {
if (typeof href$ === 'string') {
href$ = observableOf(href$);
}
const requestHref$ = href$.pipe(
isNotEmptyOperator(),
take(1),
map((href: string) => this.buildHrefFromFindOptions(href, {}, [], ...linksToFollow))
);
this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable);
return this.rdbService.buildSingle<T>(requestHref$, ...linksToFollow).pipe(
// This skip ensures that if a stale object is present in the cache when you do a
// call it isn't immediately returned, but we wait until the remote data for the new request
// is created. If useCachedVersionIfAvailable is false it also ensures you don't get a
// cached completed object
skipWhile((rd: RemoteData<T>) => useCachedVersionIfAvailable ? rd.isStale : rd.hasCompleted),
this.reRequestStaleRemoteData(reRequestOnStale, () =>
this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow))
);
}
/**
* Returns a list of observables of {@link RemoteData} of objects, based on an href, with a list
* of {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object
* @param href$ The url of object we want to retrieve. Can be a string or
* an Observable<string>
* @param findListOptions Find list options object
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
*/
findAllByHref(href$: string | Observable<string>, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<PaginatedList<T>>> {
if (typeof href$ === 'string') {
href$ = observableOf(href$);
}
const requestHref$ = href$.pipe(
isNotEmptyOperator(),
take(1),
map((href: string) => this.buildHrefFromFindOptions(href, findListOptions, [], ...linksToFollow))
);
this.createAndSendGetRequest(requestHref$, useCachedVersionIfAvailable);
return this.rdbService.buildList<T>(requestHref$, ...linksToFollow).pipe(
// This skip ensures that if a stale object is present in the cache when you do a
// call it isn't immediately returned, but we wait until the remote data for the new request
// is created. If useCachedVersionIfAvailable is false it also ensures you don't get a
// cached completed object
skipWhile((rd: RemoteData<PaginatedList<T>>) => useCachedVersionIfAvailable ? rd.isStale : rd.hasCompleted),
this.reRequestStaleRemoteData(reRequestOnStale, () =>
this.findAllByHref(href$, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow))
);
}
/**
* Create a GET request for the given href, and send it.
*
* @param href$ The url of object we want to retrieve. Can be a string or
* an Observable<string>
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
*/
protected createAndSendGetRequest(href$: string | Observable<string>, useCachedVersionIfAvailable = true): void {
if (isNotEmpty(href$)) {
if (typeof href$ === 'string') {
href$ = observableOf(href$);
}
href$.pipe(
isNotEmptyOperator(),
take(1)
).subscribe((href: string) => {
const requestId = this.requestService.generateRequestId();
const request = new GetRequest(requestId, href);
if (hasValue(this.responseMsToLive)) {
request.responseMsToLive = this.responseMsToLive;
}
this.requestService.send(request, useCachedVersionIfAvailable);
});
}
}
/**
* Return object search endpoint by given search method
*
* @param searchMethod The search method for the object
*/
protected getSearchEndpoint(searchMethod: string): Observable<string> {
return this.halService.getEndpoint(this.linkPath).pipe(
filter((href: string) => isNotEmpty(href)),
map((href: string) => `${href}/search/${searchMethod}`));
}
/**
* Make a new FindListRequest with given search method
*
* @param searchMethod The search method for the object
* @param options The [[FindListOptions]] object
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
* @return {Observable<RemoteData<PaginatedList<T>>}
* Return an observable that emits response from the server
*/
searchBy(searchMethod: string, options: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<PaginatedList<T>>> {
const hrefObs = this.getSearchByHref(searchMethod, options, ...linksToFollow);
return this.findAllByHref(hrefObs, undefined, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Send a patch request for a specified object
* @param {T} object The object to send a patch request for
* @param {Operation[]} operations The patch operations to be performed
*/
patch(object: T, operations: Operation[]): Observable<RemoteData<T>> {
const requestId = this.requestService.generateRequestId();
const hrefObs = this.halService.getEndpoint(this.linkPath).pipe(
map((endpoint: string) => this.getIDHref(endpoint, object.uuid)));
hrefObs.pipe(
find((href: string) => hasValue(href)),
).subscribe((href: string) => {
const request = new PatchRequest(requestId, href, operations);
if (hasValue(this.responseMsToLive)) {
request.responseMsToLive = this.responseMsToLive;
}
this.requestService.send(request);
});
return this.rdbService.buildFromRequestUUID(requestId);
}
createPatchFromCache(object: T): Observable<Operation[]> {
const oldVersion$ = this.findByHref(object._links.self.href, true, false);
return oldVersion$.pipe(
getFirstSucceededRemoteData(),
getRemoteDataPayload(),
map((oldVersion: T) => this.comparator.diff(oldVersion, object)));
}
/**
* Send a PUT request for the specified object
*
* @param object The object to send a put request for.
*/
put(object: T): Observable<RemoteData<T>> {
const requestId = this.requestService.generateRequestId();
const serializedObject = new DSpaceSerializer(object.constructor as GenericConstructor<{}>).serialize(object);
const request = new PutRequest(requestId, object._links.self.href, serializedObject);
if (hasValue(this.responseMsToLive)) {
request.responseMsToLive = this.responseMsToLive;
}
this.requestService.send(request);
return this.rdbService.buildFromRequestUUID(requestId);
}
/**
* Add a new patch to the object cache
* The patch is derived from the differences between the given object and its version in the object cache
* @param {DSpaceObject} object The given object
*/
update(object: T): Observable<RemoteData<T>> {
return this.createPatchFromCache(object)
.pipe(
mergeMap((operations: Operation[]) => {
if (isNotEmpty(operations)) {
this.objectCache.addPatch(object._links.self.href, operations);
}
return this.findByHref(object._links.self.href, true, true);
}
)
);
}
/**
* Create a new DSpaceObject on the server, and store the response
* in the object cache
*
* @param {CacheableObject} object
* The object to create
* @param {RequestParam[]} params
* Array with additional params to combine with query string
*/
create(object: T, ...params: RequestParam[]): Observable<RemoteData<T>> {
const requestId = this.requestService.generateRequestId();
const endpoint$ = this.getEndpoint().pipe(
isNotEmptyOperator(),
distinctUntilChanged(),
map((endpoint: string) => this.buildHrefWithParams(endpoint, params))
);
const serializedObject = new DSpaceSerializer(getClassForType(object.type)).serialize(object);
endpoint$.pipe(
take(1)
).subscribe((endpoint: string) => {
const request = new CreateRequest(requestId, endpoint, JSON.stringify(serializedObject));
if (hasValue(this.responseMsToLive)) {
request.responseMsToLive = this.responseMsToLive;
}
this.requestService.send(request);
});
const result$ = this.rdbService.buildFromRequestUUID<T>(requestId);
// TODO a dataservice is not the best place to show a notification,
// this should move up to the components that use this method
result$.pipe(
takeWhile((rd: RemoteData<T>) => rd.isLoading, true)
).subscribe((rd: RemoteData<T>) => {
if (rd.hasFailed) {
this.notificationsService.error('Server Error:', rd.errorMessage, new NotificationOptions(-1));
}
});
return result$;
}
/**
* Invalidate an existing DSpaceObject by marking all requests it is included in as stale
* @param objectId The id of the object to be invalidated
* @return An Observable that will emit `true` once all requests are stale
*/
invalidate(objectId: string): Observable<boolean> {
return this.getIDHrefObs(objectId).pipe(
switchMap((href: string) => this.invalidateByHref(href))
);
}
/**
* Invalidate an existing DSpaceObject by marking all requests it is included in as stale
* @param href The self link of the object to be invalidated
* @return An Observable that will emit `true` once all requests are stale
*/
invalidateByHref(href: string): Observable<boolean> {
const done$ = new AsyncSubject<boolean>();
this.objectCache.getByHref(href).pipe(
take(1),
switchMap((oce: ObjectCacheEntry) => observableFrom(oce.requestUUIDs).pipe(
mergeMap((requestUUID: string) => this.requestService.setStaleByUUID(requestUUID)),
toArray(),
)),
).subscribe(() => {
done$.next(true);
done$.complete();
});
return done$;
}
/**
* Delete an existing DSpace Object on the server
* @param objectId The id of the object to be removed
* @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual
* metadata should be saved as real metadata
* @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode,
* errorMessage, timeCompleted, etc
*/
delete(objectId: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> {
return this.getIDHrefObs(objectId).pipe(
switchMap((href: string) => this.deleteByHref(href, copyVirtualMetadata))
);
}
/**
* Delete an existing DSpace Object on the server
* @param href The self link of the object to be removed
* @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual
* metadata should be saved as real metadata
* @return A RemoteData observable with an empty payload, but still representing the state of the request: statusCode,
* errorMessage, timeCompleted, etc
* Only emits once all request related to the DSO has been invalidated.
*/
deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable<RemoteData<NoContent>> {
const requestId = this.requestService.generateRequestId();
if (copyVirtualMetadata) {
copyVirtualMetadata.forEach((id) =>
href += (href.includes('?') ? '&' : '?')
+ 'copyVirtualMetadata='
+ id
);
}
const request = new DeleteRequest(requestId, href);
if (hasValue(this.responseMsToLive)) {
request.responseMsToLive = this.responseMsToLive;
}
this.requestService.send(request);
const response$ = this.rdbService.buildFromRequestUUID(requestId);
const invalidated$ = new AsyncSubject<boolean>();
response$.pipe(
getFirstCompletedRemoteData(),
switchMap((rd: RemoteData<NoContent>) => {
if (rd.hasSucceeded) {
return this.invalidateByHref(href);
} else {
return [true];
}
})
).subscribe(() => {
invalidated$.next(true);
invalidated$.complete();
});
return combineLatest([response$, invalidated$]).pipe(
filter(([_, invalidated]) => invalidated),
map(([response, _]) => response),
);
}
/**
* Commit current object changes to the server
* @param method The RestRequestMethod for which de server sync buffer should be committed
*/
commitUpdates(method?: RestRequestMethod) {
this.requestService.commit(method);
}
/**
* Return the links to traverse from the root of the api to the
* endpoint this DataService represents
*
* e.g. if the api root links to 'foo', and the endpoint at 'foo'
* links to 'bar' the linkPath for the BarDataService would be
* 'foo/bar'
*/
getLinkPath(): string {
return this.linkPath;
}
}

View File

@@ -1,95 +0,0 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { hasValue } from '../../shared/empty.util';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { DataService } from './data.service';
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
import { RemoteData } from './remote-data';
import { IdentifierType } from './request.models';
import { RequestService } from './request.service';
import { getFirstCompletedRemoteData } from '../shared/operators';
import { DSpaceObject } from '../shared/dspace-object.model';
import { Item } from '../shared/item.model';
import { getItemPageRoute } from '../../item-page/item-page-routing-paths';
import { CoreState } from '../core-state.model';
@Injectable()
export class DsoRedirectDataService extends DataService<any> {
// Set the default link path to the identifier lookup endpoint.
protected linkPath = 'pid';
private uuidEndpoint = 'dso';
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DSOChangeAnalyzer<any>,
private router: Router) {
super();
}
setLinkPath(identifierType: IdentifierType) {
// The default 'pid' endpoint for identifiers does not support uuid lookups.
// For uuid lookups we need to change the linkPath.
if (identifierType === IdentifierType.UUID) {
this.linkPath = this.uuidEndpoint;
}
}
getIDHref(endpoint, resourceID, ...linksToFollow: FollowLinkConfig<any>[]): string {
// Supporting both identifier (pid) and uuid (dso) endpoints
return this.buildHrefFromFindOptions( endpoint.replace(/\{\?id\}/, `?id=${resourceID}`)
.replace(/\{\?uuid\}/, `?uuid=${resourceID}`),
{}, [], ...linksToFollow);
}
findByIdAndIDType(id: string, identifierType = IdentifierType.UUID): Observable<RemoteData<DSpaceObject>> {
this.setLinkPath(identifierType);
return this.findById(id).pipe(
getFirstCompletedRemoteData(),
tap((response) => {
if (response.hasSucceeded) {
const dso = response.payload;
const uuid = dso.uuid;
if (hasValue(uuid)) {
let newRoute = this.getEndpointFromDSOType(response.payload.type);
if (dso.type.startsWith('item')) {
newRoute = getItemPageRoute(dso as Item);
} else if (hasValue(newRoute)) {
newRoute += '/' + uuid;
}
if (hasValue(newRoute)) {
this.router.navigate([newRoute]);
}
}
}
})
);
}
// Is there an existing method somewhere else that converts dso type to route?
getEndpointFromDSOType(dsoType: string): string {
// Are there other types to consider?
if (dsoType.startsWith('item')) {
return 'items';
} else if (dsoType.startsWith('community')) {
return 'communities';
} else if (dsoType.startsWith('collection')) {
return 'collections';
} else {
return '';
}
}
}

View File

@@ -1,22 +1,18 @@
import { HttpClient } from '@angular/common/http';
import { Store } from '@ngrx/store';
import { cold, getTestScheduler } from 'jasmine-marbles'; import { cold, getTestScheduler } from 'jasmine-marbles';
import { TestScheduler } from 'rxjs/testing'; import { TestScheduler } from 'rxjs/testing';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { followLink } from '../../shared/utils/follow-link-config.model'; import { followLink } from '../../shared/utils/follow-link-config.model';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { DsoRedirectDataService } from './dso-redirect-data.service'; import { DsoRedirectService } from './dso-redirect.service';
import { GetRequest, IdentifierType } from './request.models'; import { GetRequest, IdentifierType } from './request.models';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils';
import { Item } from '../shared/item.model'; import { Item } from '../shared/item.model';
import { CoreState } from '../core-state.model';
describe('DsoRedirectDataService', () => { describe('DsoRedirectService', () => {
let scheduler: TestScheduler; let scheduler: TestScheduler;
let service: DsoRedirectDataService; let service: DsoRedirectService;
let halService: HALEndpointService; let halService: HALEndpointService;
let requestService: RequestService; let requestService: RequestService;
let rdbService: RemoteDataBuildService; let rdbService: RemoteDataBuildService;
@@ -29,10 +25,6 @@ describe('DsoRedirectDataService', () => {
const requestHandleURL = `https://rest.api/rest/api/pid/find?id=${encodedHandle}`; const requestHandleURL = `https://rest.api/rest/api/pid/find?id=${encodedHandle}`;
const requestUUIDURL = `https://rest.api/rest/api/pid/find?id=${dsoUUID}`; const requestUUIDURL = `https://rest.api/rest/api/pid/find?id=${dsoUUID}`;
const requestUUID = '34cfed7c-f597-49ef-9cbe-ea351f0023c2'; const requestUUID = '34cfed7c-f597-49ef-9cbe-ea351f0023c2';
const store = {} as Store<CoreState>;
const notificationsService = {} as NotificationsService;
const http = {} as HttpClient;
const comparator = {} as any;
const objectCache = {} as ObjectCacheService; const objectCache = {} as ObjectCacheService;
beforeEach(() => { beforeEach(() => {
@@ -59,20 +51,16 @@ describe('DsoRedirectDataService', () => {
a: remoteData a: remoteData
}) })
}); });
service = new DsoRedirectDataService( service = new DsoRedirectService(
requestService, requestService,
rdbService, rdbService,
store,
objectCache, objectCache,
halService, halService,
notificationsService, router,
http,
comparator,
router
); );
}); });
describe('findById', () => { describe('findByIdAndIDType', () => {
it('should call HALEndpointService with the path to the pid endpoint', () => { it('should call HALEndpointService with the path to the pid endpoint', () => {
scheduler.schedule(() => service.findByIdAndIDType(dsoHandle, IdentifierType.HANDLE)); scheduler.schedule(() => service.findByIdAndIDType(dsoHandle, IdentifierType.HANDLE));
scheduler.flush(); scheduler.flush();
@@ -141,7 +129,7 @@ describe('DsoRedirectDataService', () => {
redir.subscribe(); redir.subscribe();
scheduler.schedule(() => redir); scheduler.schedule(() => redir);
scheduler.flush(); scheduler.flush();
expect(router.navigate).toHaveBeenCalledWith([remoteData.payload.type + 's/' + remoteData.payload.uuid]); expect(router.navigate).toHaveBeenCalledWith(['/collections/' + remoteData.payload.uuid]);
}); });
it('should navigate to communities route', () => { it('should navigate to communities route', () => {
@@ -150,55 +138,58 @@ describe('DsoRedirectDataService', () => {
redir.subscribe(); redir.subscribe();
scheduler.schedule(() => redir); scheduler.schedule(() => redir);
scheduler.flush(); scheduler.flush();
expect(router.navigate).toHaveBeenCalledWith(['communities/' + remoteData.payload.uuid]); expect(router.navigate).toHaveBeenCalledWith(['/communities/' + remoteData.payload.uuid]);
}); });
}); });
describe('getIDHref', () => { describe('DataService', () => { // todo: should only test the id/uuid interpolation thingy
describe('getIDHref', () => { // todo: should be able to move this up to IdentifiableDataService?
it('should return endpoint', () => { it('should return endpoint', () => {
const result = (service as any).getIDHref(pidLink, dsoUUID); const result = (service as any).dataService.getIDHref(pidLink, dsoUUID);
expect(result).toEqual(requestUUIDURL); expect(result).toEqual(requestUUIDURL);
}); });
it('should include single linksToFollow as embed', () => { it('should include single linksToFollow as embed', () => {
const expected = `${requestUUIDURL}&embed=bundles`; const expected = `${requestUUIDURL}&embed=bundles`;
const result = (service as any).getIDHref(pidLink, dsoUUID, followLink('bundles')); const result = (service as any).dataService.getIDHref(pidLink, dsoUUID, followLink('bundles'));
expect(result).toEqual(expected); expect(result).toEqual(expected);
}); });
it('should include multiple linksToFollow as embed', () => { it('should include multiple linksToFollow as embed', () => {
const expected = `${requestUUIDURL}&embed=bundles&embed=owningCollection&embed=templateItemOf`; const expected = `${requestUUIDURL}&embed=bundles&embed=owningCollection&embed=templateItemOf`;
const result = (service as any).getIDHref(pidLink, dsoUUID, followLink('bundles'), followLink('owningCollection'), followLink('templateItemOf')); const result = (service as any).dataService.getIDHref(pidLink, dsoUUID, followLink('bundles'), followLink('owningCollection'), followLink('templateItemOf'));
expect(result).toEqual(expected); expect(result).toEqual(expected);
}); });
it('should not include linksToFollow with shouldEmbed = false', () => { it('should not include linksToFollow with shouldEmbed = false', () => {
const expected = `${requestUUIDURL}&embed=templateItemOf`; const expected = `${requestUUIDURL}&embed=templateItemOf`;
const result = (service as any).getIDHref( const result = (service as any).dataService.getIDHref(
pidLink, pidLink,
dsoUUID, dsoUUID,
followLink('bundles', { shouldEmbed: false }), followLink('bundles', { shouldEmbed: false }),
followLink('owningCollection', { shouldEmbed: false }), followLink('owningCollection', { shouldEmbed: false }),
followLink('templateItemOf') followLink('templateItemOf'),
); );
expect(result).toEqual(expected); expect(result).toEqual(expected);
}); });
it('should include nested linksToFollow 3lvl', () => { it('should include nested linksToFollow 3lvl', () => {
const expected = `${requestUUIDURL}&embed=owningCollection/itemtemplate/relationships`; const expected = `${requestUUIDURL}&embed=owningCollection/itemtemplate/relationships`;
const result = (service as any).getIDHref( const result = (service as any).dataService.getIDHref(
pidLink, pidLink,
dsoUUID, dsoUUID,
followLink('owningCollection', followLink(
'owningCollection',
{}, {},
followLink('itemtemplate', followLink(
'itemtemplate',
{}, {},
followLink('relationships') followLink('relationships'),
) ),
) ),
); );
expect(result).toEqual(expected); expect(result).toEqual(expected);
}); });
}); });
});
}); });

View File

@@ -0,0 +1,104 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
/* eslint-disable max-classes-per-file */
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { hasValue } from '../../shared/empty.util';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { RemoteData } from './remote-data';
import { IdentifierType } from './request.models';
import { RequestService } from './request.service';
import { getFirstCompletedRemoteData } from '../shared/operators';
import { DSpaceObject } from '../shared/dspace-object.model';
import { IdentifiableDataService } from './base/identifiable-data.service';
import { getDSORoute } from '../../app-routing-paths';
const ID_ENDPOINT = 'pid';
const UUID_ENDPOINT = 'dso';
/**
* A data service to retrieve DSpaceObjects by persistent identifier or UUID.
* Doesn't define a constant {@link linkPath} but switches between two endpoints on demand:
* {@link setLinkPath} must be called before each request.
*/
class DsoByIdOrUUIDDataService extends IdentifiableDataService<DSpaceObject> {
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
) {
super(
undefined, requestService, rdbService, objectCache, halService, undefined,
// interpolate id/uuid as query parameter
(endpoint: string, resourceID: string): string => {
return endpoint.replace(/{\?id}/, `?id=${resourceID}`)
.replace(/{\?uuid}/, `?uuid=${resourceID}`);
},
);
}
/**
* The default 'pid' endpoint for identifiers does not support uuid lookups.
* For uuid lookups we need to change the linkPath.
* @param identifierType
*/
setLinkPath(identifierType: IdentifierType) {
if (identifierType === IdentifierType.UUID) {
this.linkPath = UUID_ENDPOINT;
} else {
this.linkPath = ID_ENDPOINT;
}
}
}
/**
* A service to handle redirects from identifier paths to DSO path
* e.g.: redirect from /handle/... to /items/...
*/
@Injectable()
export class DsoRedirectService {
private dataService: DsoByIdOrUUIDDataService;
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
private router: Router,
) {
this.dataService = new DsoByIdOrUUIDDataService(requestService, rdbService, objectCache, halService);
}
/**
* Retrieve a DSpaceObject by
* @param id the identifier of the object to retrieve
* @param identifierType the type of the given identifier (defaults to UUID)
*/
findByIdAndIDType(id: string, identifierType = IdentifierType.UUID): Observable<RemoteData<DSpaceObject>> {
this.dataService.setLinkPath(identifierType);
return this.dataService.findById(id).pipe(
getFirstCompletedRemoteData(),
tap((response) => {
if (response.hasSucceeded) {
const dso = response.payload;
if (hasValue(dso.uuid)) {
let newRoute = getDSORoute(dso);
if (hasValue(newRoute)) {
this.router.navigate([newRoute]);
}
}
}
})
);
}
}

View File

@@ -7,8 +7,6 @@ import { GetRequest } from './request.models';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
import { DSpaceObjectDataService } from './dspace-object-data.service'; import { DSpaceObjectDataService } from './dspace-object-data.service';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
describe('DSpaceObjectDataService', () => { describe('DSpaceObjectDataService', () => {
let scheduler: TestScheduler; let scheduler: TestScheduler;
@@ -42,18 +40,12 @@ describe('DSpaceObjectDataService', () => {
}) })
}); });
objectCache = {} as ObjectCacheService; objectCache = {} as ObjectCacheService;
const notificationsService = {} as NotificationsService;
const http = {} as HttpClient;
const comparator = {} as any;
service = new DSpaceObjectDataService( service = new DSpaceObjectDataService(
requestService, requestService,
rdbService, rdbService,
objectCache, objectCache,
halService, halService,
notificationsService,
http,
comparator
); );
}); });

View File

@@ -1,106 +1,28 @@
/* eslint-disable max-classes-per-file */
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { dataService } from '../cache/builders/build-decorators';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { DSpaceObject } from '../shared/dspace-object.model'; import { DSpaceObject } from '../shared/dspace-object.model';
import { DSPACE_OBJECT } from '../shared/dspace-object.resource-type'; import { DSPACE_OBJECT } from '../shared/dspace-object.resource-type';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { DataService } from './data.service';
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
import { RemoteData } from './remote-data';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
import { PaginatedList } from './paginated-list.model'; import { IdentifiableDataService } from './base/identifiable-data.service';
import { CoreState } from '../core-state.model'; import { dataService } from './base/data-service.decorator';
import { FindListOptions } from './find-list-options.model';
class DataServiceImpl extends DataService<DSpaceObject> {
protected linkPath = 'dso';
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DSOChangeAnalyzer<DSpaceObject>) {
super();
}
getIDHref(endpoint, resourceID, ...linksToFollow: FollowLinkConfig<DSpaceObject>[]): string {
return this.buildHrefFromFindOptions( endpoint.replace(/\{\?uuid\}/, `?uuid=${resourceID}`),
{}, [], ...linksToFollow);
}
}
@Injectable() @Injectable()
@dataService(DSPACE_OBJECT) @dataService(DSPACE_OBJECT)
export class DSpaceObjectDataService { export class DSpaceObjectDataService extends IdentifiableDataService<DSpaceObject> {
protected linkPath = 'dso';
private dataService: DataServiceImpl;
constructor( constructor(
protected requestService: RequestService, protected requestService: RequestService,
protected rdbService: RemoteDataBuildService, protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
protected halService: HALEndpointService, protected halService: HALEndpointService,
protected notificationsService: NotificationsService, ) {
protected http: HttpClient, super(
protected comparator: DSOChangeAnalyzer<DSpaceObject>) { 'dso', requestService, rdbService, objectCache, halService, undefined,
this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); // interpolate uuid as query parameter
(endpoint: string, resourceID: string): string => {
return endpoint.replace(/{\?uuid}/, `?uuid=${resourceID}`);
},
);
} }
/**
* Returns an observable of {@link RemoteData} of an object, based on its ID, with a list of {@link FollowLinkConfig},
* to automatically resolve {@link HALLink}s of the object
* @param id ID of object we want to retrieve
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
*/
findById(id: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<DSpaceObject>[]): Observable<RemoteData<DSpaceObject>> {
return this.dataService.findById(id, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Returns an observable of {@link RemoteData} of an object, based on an href, with a list of {@link FollowLinkConfig},
* to automatically resolve {@link HALLink}s of the object
* @param href The url of object we want to retrieve
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
*/
findByHref(href: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<DSpaceObject>[]): Observable<RemoteData<DSpaceObject>> {
return this.dataService.findByHref(href, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Returns a list of observables of {@link RemoteData} of objects, based on an href, with a list of {@link FollowLinkConfig},
* to automatically resolve {@link HALLink}s of the object
* @param href The url of object we want to retrieve
* @param findListOptions Find list options object
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
*/
findAllByHref(href: string, findListOptions: FindListOptions = {}, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<DSpaceObject>[]): Observable<RemoteData<PaginatedList<DSpaceObject>>> {
return this.dataService.findAllByHref(href, findListOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
} }

View File

@@ -0,0 +1,19 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { testFindAllDataImplementation } from './base/find-all-data.spec';
import { testSearchDataImplementation } from './base/search-data.spec';
import { EntityTypeDataService } from './entity-type-data.service';
describe('EntityTypeDataService', () => {
describe('composition', () => {
const initService = () => new EntityTypeDataService(null, null, null, null, null);
testFindAllDataImplementation(initService);
testSearchDataImplementation(initService);
});
});

View File

@@ -1,13 +1,8 @@
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { DataService } from './data.service';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { Store } from '@ngrx/store';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { filter, map, switchMap, take } from 'rxjs/operators'; import { filter, map, switchMap, take } from 'rxjs/operators';
@@ -16,28 +11,31 @@ import { RelationshipType } from '../shared/item-relationships/relationship-type
import { PaginatedList } from './paginated-list.model'; import { PaginatedList } from './paginated-list.model';
import { ItemType } from '../shared/item-relationships/item-type.model'; import { ItemType } from '../shared/item-relationships/item-type.model';
import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../shared/operators'; import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../shared/operators';
import { RelationshipTypeService } from './relationship-type.service'; import { RelationshipTypeDataService } from './relationship-type-data.service';
import { CoreState } from '../core-state.model';
import { FindListOptions } from './find-list-options.model'; import { FindListOptions } from './find-list-options.model';
import { BaseDataService } from './base/base-data.service';
import { SearchData, SearchDataImpl } from './base/search-data';
import { FindAllData, FindAllDataImpl } from './base/find-all-data';
/** /**
* Service handling all ItemType requests * Service handling all ItemType requests
*/ */
@Injectable() @Injectable()
export class EntityTypeService extends DataService<ItemType> { export class EntityTypeDataService extends BaseDataService<ItemType> implements FindAllData<ItemType>, SearchData<ItemType> {
private findAllData: FindAllData<ItemType>;
private searchData: SearchDataImpl<ItemType>;
protected linkPath = 'entitytypes'; constructor(
protected requestService: RequestService,
constructor(protected requestService: RequestService,
protected rdbService: RemoteDataBuildService, protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected halService: HALEndpointService,
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
protected notificationsService: NotificationsService, protected halService: HALEndpointService,
protected relationshipTypeService: RelationshipTypeService, protected relationshipTypeService: RelationshipTypeDataService,
protected http: HttpClient, ) {
protected comparator: DefaultChangeAnalyzer<ItemType>) { super('entitytypes', requestService, rdbService, objectCache, halService);
super();
this.findAllData = new FindAllDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
} }
getBrowseEndpoint(options, linkPath?: string): Observable<string> { getBrowseEndpoint(options, linkPath?: string): Observable<string> {
@@ -147,7 +145,7 @@ export class EntityTypeService extends DataService<ItemType> {
*/ */
getEntityTypeRelationships(entityTypeId: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<RelationshipType>[]): Observable<RemoteData<PaginatedList<RelationshipType>>> { getEntityTypeRelationships(entityTypeId: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<RelationshipType>[]): Observable<RemoteData<PaginatedList<RelationshipType>>> {
const href$ = this.getRelationshipTypesEndpoint(entityTypeId); const href$ = this.getRelationshipTypesEndpoint(entityTypeId);
return this.relationshipTypeService.findAllByHref(href$, undefined, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow); return this.relationshipTypeService.findListByHref(href$, undefined, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
} }
/** /**
@@ -158,7 +156,43 @@ export class EntityTypeService extends DataService<ItemType> {
return this.halService.getEndpoint(this.linkPath).pipe( return this.halService.getEndpoint(this.linkPath).pipe(
take(1), take(1),
switchMap((endPoint: string) => switchMap((endPoint: string) =>
this.findByHref(endPoint + '/label/' + label)) this.findByHref(endPoint + '/label/' + label)),
); );
} }
/**
* Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded
* info should be added to the objects
*
* @param options Find list options object
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
* @return {Observable<RemoteData<PaginatedList<T>>>}
* Return an observable that emits object list
*/
public findAll(options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<ItemType>[]): Observable<RemoteData<PaginatedList<ItemType>>> {
return this.findAllData.findAll(options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
/**
* Make a new FindListRequest with given search method
*
* @param searchMethod The search method for the object
* @param options The [[FindListOptions]] object
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
* @return {Observable<RemoteData<PaginatedList<T>>}
* Return an observable that emits response from the server
*/
searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<ItemType>[]): Observable<RemoteData<PaginatedList<ItemType>>> {
return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
} }

View File

@@ -13,11 +13,9 @@ import { RegistrationResponseParsingService } from './registration-response-pars
import { RemoteData } from './remote-data'; import { RemoteData } from './remote-data';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
@Injectable( @Injectable({
{
providedIn: 'root', providedIn: 'root',
} })
)
/** /**
* Service that will register a new email address and request a token * Service that will register a new email address and request a token
*/ */

View File

@@ -1,12 +1,13 @@
import { ExternalSourceService } from './external-source.service'; import { ExternalSourceDataService } from './external-source-data.service';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { createPaginatedList } from '../../shared/testing/utils.test'; import { createPaginatedList } from '../../shared/testing/utils.test';
import { ExternalSourceEntry } from '../shared/external-source-entry.model'; import { ExternalSourceEntry } from '../shared/external-source-entry.model';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { GetRequest } from './request.models'; import { GetRequest } from './request.models';
import { testSearchDataImplementation } from './base/search-data.spec';
describe('ExternalSourceService', () => { describe('ExternalSourceService', () => {
let service: ExternalSourceService; let service: ExternalSourceDataService;
let requestService; let requestService;
let rdbService; let rdbService;
@@ -48,15 +49,20 @@ describe('ExternalSourceService', () => {
buildList: createSuccessfulRemoteDataObject$(createPaginatedList(entries)) buildList: createSuccessfulRemoteDataObject$(createPaginatedList(entries))
}); });
halService = jasmine.createSpyObj('halService', { halService = jasmine.createSpyObj('halService', {
getEndpoint: observableOf('external-sources-REST-endpoint') getEndpoint: observableOf('external-sources-REST-endpoint'),
}); });
service = new ExternalSourceService(requestService, rdbService, undefined, undefined, halService, undefined, undefined, undefined); service = new ExternalSourceDataService(requestService, rdbService, undefined, halService);
} }
beforeEach(() => { beforeEach(() => {
init(); init();
}); });
describe('composition', () => {
const initService = () => new ExternalSourceDataService(null, null, null, null);
testSearchDataImplementation(initService);
});
describe('getExternalSourceEntries', () => { describe('getExternalSourceEntries', () => {
let result; let result;

View File

@@ -1,13 +1,9 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { DataService } from './data.service';
import { ExternalSource } from '../shared/external-source.model'; import { ExternalSource } from '../shared/external-source.model';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { Store } from '@ngrx/store';
import { ObjectCacheService } from '../cache/object-cache.service'; import { ObjectCacheService } from '../cache/object-cache.service';
import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointService } from '../shared/hal-endpoint.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { distinctUntilChanged, map, switchMap, take } from 'rxjs/operators'; import { distinctUntilChanged, map, switchMap, take } from 'rxjs/operators';
import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model'; import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model';
@@ -15,28 +11,27 @@ import { hasValue, isNotEmptyOperator } from '../../shared/empty.util';
import { RemoteData } from './remote-data'; import { RemoteData } from './remote-data';
import { PaginatedList } from './paginated-list.model'; import { PaginatedList } from './paginated-list.model';
import { ExternalSourceEntry } from '../shared/external-source-entry.model'; import { ExternalSourceEntry } from '../shared/external-source-entry.model';
import { DefaultChangeAnalyzer } from './default-change-analyzer.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { CoreState } from '../core-state.model';
import { FindListOptions } from './find-list-options.model'; import { FindListOptions } from './find-list-options.model';
import { IdentifiableDataService } from './base/identifiable-data.service';
import { SearchData, SearchDataImpl } from './base/search-data';
/** /**
* A service handling all external source requests * A service handling all external source requests
*/ */
@Injectable() @Injectable()
export class ExternalSourceService extends DataService<ExternalSource> { export class ExternalSourceDataService extends IdentifiableDataService<ExternalSource> implements SearchData<ExternalSource> {
protected linkPath = 'externalsources'; private searchData: SearchData<ExternalSource>;
constructor( constructor(
protected requestService: RequestService, protected requestService: RequestService,
protected rdbService: RemoteDataBuildService, protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
protected halService: HALEndpointService, protected halService: HALEndpointService,
protected notificationsService: NotificationsService, ) {
protected http: HttpClient, super('externalsources', requestService, rdbService, objectCache, halService);
protected comparator: DefaultChangeAnalyzer<ExternalSource>) {
super(); this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
} }
/** /**
@@ -75,10 +70,28 @@ export class ExternalSourceService extends DataService<ExternalSource> {
isNotEmptyOperator(), isNotEmptyOperator(),
distinctUntilChanged(), distinctUntilChanged(),
map((endpoint: string) => hasValue(searchOptions) ? searchOptions.toRestUrl(endpoint) : endpoint), map((endpoint: string) => hasValue(searchOptions) ? searchOptions.toRestUrl(endpoint) : endpoint),
take(1) take(1),
); );
// TODO create a dedicated ExternalSourceEntryDataService and move this entire method to it. Then the "as any"s won't be necessary // TODO create a dedicated ExternalSourceEntryDataService and move this entire method to it. Then the "as any"s won't be necessary
return this.findAllByHref(href$, undefined, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow as any) as any; return this.findListByHref(href$, undefined, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow as any) as any;
}
/**
* Make a new FindListRequest with given search method
*
* @param searchMethod The search method for the object
* @param options The [[FindListOptions]] object
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
* @return {Observable<RemoteData<PaginatedList<T>>}
* Return an observable that emits response from the server
*/
public searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<ExternalSource>[]): Observable<RemoteData<PaginatedList<ExternalSource>>> {
return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
} }
} }

View File

@@ -1,6 +1,5 @@
import { AuthorizationDataService } from './authorization-data.service'; import { AuthorizationDataService } from './authorization-data.service';
import { SiteDataService } from '../site-data.service'; import { SiteDataService } from '../site-data.service';
import { AuthService } from '../../auth/auth.service';
import { Site } from '../../shared/site.model'; import { Site } from '../../shared/site.model';
import { EPerson } from '../../eperson/models/eperson.model'; import { EPerson } from '../../eperson/models/eperson.model';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
@@ -12,11 +11,11 @@ import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from
import { createPaginatedList } from '../../../shared/testing/utils.test'; import { createPaginatedList } from '../../../shared/testing/utils.test';
import { Feature } from '../../shared/feature.model'; import { Feature } from '../../shared/feature.model';
import { FindListOptions } from '../find-list-options.model'; import { FindListOptions } from '../find-list-options.model';
import { testSearchDataImplementation } from '../base/search-data.spec';
describe('AuthorizationDataService', () => { describe('AuthorizationDataService', () => {
let service: AuthorizationDataService; let service: AuthorizationDataService;
let siteService: SiteDataService; let siteService: SiteDataService;
let authService: AuthService;
let site: Site; let site: Site;
let ePerson: EPerson; let ePerson: EPerson;
@@ -37,13 +36,9 @@ describe('AuthorizationDataService', () => {
uuid: 'test-eperson' uuid: 'test-eperson'
}); });
siteService = jasmine.createSpyObj('siteService', { siteService = jasmine.createSpyObj('siteService', {
find: observableOf(site) find: observableOf(site),
}); });
authService = { service = new AuthorizationDataService(requestService, undefined, undefined, undefined, siteService);
isAuthenticated: () => observableOf(true),
getAuthenticatedUserFromStore: () => observableOf(ePerson)
} as AuthService;
service = new AuthorizationDataService(requestService, undefined, undefined, undefined, undefined, undefined, undefined, undefined, authService, siteService);
} }
beforeEach(() => { beforeEach(() => {
@@ -51,6 +46,11 @@ describe('AuthorizationDataService', () => {
spyOn(service, 'searchBy').and.returnValue(observableOf(undefined)); spyOn(service, 'searchBy').and.returnValue(observableOf(undefined));
}); });
describe('composition', () => {
const initService = () => new AuthorizationDataService(null, null, null, null, null);
testSearchDataImplementation(initService);
});
it('should call setStaleByHrefSubstring method', () => { it('should call setStaleByHrefSubstring method', () => {
service.invalidateAuthorizationsRequestCache(); service.invalidateAuthorizationsRequestCache();
expect((service as any).requestService.setStaleByHrefSubstring).toHaveBeenCalledWith((service as any).linkPath); expect((service as any).requestService.setStaleByHrefSubstring).toHaveBeenCalledWith((service as any).linkPath);

View File

@@ -1,18 +1,11 @@
import { Observable, of as observableOf } from 'rxjs'; import { Observable, of as observableOf } from 'rxjs';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { AUTHORIZATION } from '../../shared/authorization.resource-type'; import { AUTHORIZATION } from '../../shared/authorization.resource-type';
import { dataService } from '../../cache/builders/build-decorators';
import { DataService } from '../data.service';
import { Authorization } from '../../shared/authorization.model'; import { Authorization } from '../../shared/authorization.model';
import { RequestService } from '../request.service'; import { RequestService } from '../request.service';
import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../../cache/builders/remote-data-build.service';
import { Store } from '@ngrx/store';
import { ObjectCacheService } from '../../cache/object-cache.service'; import { ObjectCacheService } from '../../cache/object-cache.service';
import { HALEndpointService } from '../../shared/hal-endpoint.service'; import { HALEndpointService } from '../../shared/hal-endpoint.service';
import { NotificationsService } from '../../../shared/notifications/notifications.service';
import { HttpClient } from '@angular/common/http';
import { DSOChangeAnalyzer } from '../dso-change-analyzer.service';
import { AuthService } from '../../auth/auth.service';
import { SiteDataService } from '../site-data.service'; import { SiteDataService } from '../site-data.service';
import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
import { RemoteData } from '../remote-data'; import { RemoteData } from '../remote-data';
@@ -24,31 +17,32 @@ import { AuthorizationSearchParams } from './authorization-search-params';
import { addSiteObjectUrlIfEmpty, oneAuthorizationMatchesFeature } from './authorization-utils'; import { addSiteObjectUrlIfEmpty, oneAuthorizationMatchesFeature } from './authorization-utils';
import { FeatureID } from './feature-id'; import { FeatureID } from './feature-id';
import { getFirstCompletedRemoteData } from '../../shared/operators'; import { getFirstCompletedRemoteData } from '../../shared/operators';
import { CoreState } from '../../core-state.model';
import { FindListOptions } from '../find-list-options.model'; import { FindListOptions } from '../find-list-options.model';
import { BaseDataService } from '../base/base-data.service';
import { SearchData, SearchDataImpl } from '../base/search-data';
import { dataService } from '../base/data-service.decorator';
/** /**
* A service to retrieve {@link Authorization}s from the REST API * A service to retrieve {@link Authorization}s from the REST API
*/ */
@Injectable() @Injectable()
@dataService(AUTHORIZATION) @dataService(AUTHORIZATION)
export class AuthorizationDataService extends DataService<Authorization> { export class AuthorizationDataService extends BaseDataService<Authorization> implements SearchData<Authorization> {
protected linkPath = 'authorizations'; protected linkPath = 'authorizations';
protected searchByObjectPath = 'object'; protected searchByObjectPath = 'object';
private searchData: SearchDataImpl<Authorization>;
constructor( constructor(
protected requestService: RequestService, protected requestService: RequestService,
protected rdbService: RemoteDataBuildService, protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService, protected objectCache: ObjectCacheService,
protected halService: HALEndpointService, protected halService: HALEndpointService,
protected notificationsService: NotificationsService, protected siteService: SiteDataService,
protected http: HttpClient,
protected comparator: DSOChangeAnalyzer<Authorization>,
protected authService: AuthService,
protected siteService: SiteDataService
) { ) {
super(); super('authorizations', requestService, rdbService, objectCache, halService);
this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
} }
/** /**
@@ -130,7 +124,25 @@ export class AuthorizationDataService extends DataService<Authorization> {
params.push(new RequestParam('eperson', ePersonUuid)); params.push(new RequestParam('eperson', ePersonUuid));
} }
return Object.assign(new FindListOptions(), options, { return Object.assign(new FindListOptions(), options, {
searchParams: [...params] searchParams: [...params],
}); });
} }
/**
* Make a new FindListRequest with given search method
*
* @param searchMethod The search method for the object
* @param options The [[FindListOptions]] object
* @param useCachedVersionIfAvailable If this is true, the request will only be sent if there's
* no valid cached version. Defaults to true
* @param reRequestOnStale Whether or not the request should automatically be re-
* requested after the response becomes stale
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which
* {@link HALLink}s should be automatically resolved
* @return {Observable<RemoteData<PaginatedList<T>>}
* Return an observable that emits response from the server
*/
searchBy(searchMethod: string, options?: FindListOptions, useCachedVersionIfAvailable?: boolean, reRequestOnStale?: boolean, ...linksToFollow: FollowLinkConfig<Authorization>[]): Observable<RemoteData<PaginatedList<Authorization>>> {
return this.searchData.searchBy(searchMethod, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}
} }

View File

@@ -19,7 +19,7 @@ export abstract class SomeFeatureAuthorizationGuard implements CanActivate {
/** /**
* True when user has authorization rights for the feature and object provided * True when user has authorization rights for the feature and object provided
* Redirect the user to the unauthorized page when he/she's not authorized for the given feature * Redirect the user to the unauthorized page when they are not authorized for the given feature
*/ */
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> { canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
return observableCombineLatest(this.getFeatureIDs(route, state), this.getObjectUrl(route, state), this.getEPersonUuid(route, state)).pipe( return observableCombineLatest(this.getFeatureIDs(route, state), this.getObjectUrl(route, state), this.getEPersonUuid(route, state)).pipe(

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