Merge remote-tracking branch 'remotes/origin/master' into shibboleth

# Conflicts:
#	src/app/core/auth/auth.actions.ts
#	src/app/core/auth/auth.effects.spec.ts
#	src/app/core/auth/auth.effects.ts
#	src/app/core/auth/auth.reducer.spec.ts
#	src/app/core/auth/auth.reducer.ts
#	src/app/core/auth/auth.service.spec.ts
#	src/app/core/auth/auth.service.ts
#	src/app/core/auth/server-auth.service.ts
#	src/app/shared/testing/auth-request-service-stub.ts
This commit is contained in:
Giuseppe Digilio
2020-03-03 20:04:02 +01:00
113 changed files with 3503 additions and 2724 deletions

View File

@@ -6,5 +6,7 @@ WORKDIR /app
ADD . /app/ ADD . /app/
EXPOSE 3000 EXPOSE 3000
RUN yarn install # We run yarn install with an increased network timeout (5min) to avoid "ESOCKETTIMEDOUT" errors from hub.docker.com
# See, for example https://github.com/yarnpkg/yarn/issues/5540
RUN yarn install --network-timeout 300000
CMD yarn run watch CMD yarn run watch

View File

@@ -140,7 +140,7 @@ module.exports = {
}, { }, {
code: 'nl', code: 'nl',
label: 'Nederlands', label: 'Nederlands',
active: false, active: true,
}, { }, {
code: 'pt', code: 'pt',
label: 'Português', label: 'Português',

View File

@@ -196,6 +196,14 @@
"browse.metadata.title": "Title", "browse.metadata.title": "Title",
"browse.metadata.author.breadcrumbs": "Browse by Author",
"browse.metadata.dateissued.breadcrumbs": "Browse by Date",
"browse.metadata.subject.breadcrumbs": "Browse by Subject",
"browse.metadata.title.breadcrumbs": "Browse by Title",
"browse.startsWith.choose_start": "(Choose start)", "browse.startsWith.choose_start": "(Choose start)",
"browse.startsWith.choose_year": "(Choose year)", "browse.startsWith.choose_year": "(Choose year)",
@@ -237,7 +245,6 @@
"browse.title": "Browsing {{ collection }} by {{ field }} {{ value }}", "browse.title": "Browsing {{ collection }} by {{ field }} {{ value }}",
"chips.remove": "Remove chip", "chips.remove": "Remove chip",
@@ -266,6 +273,8 @@
"collection.edit.head": "Edit Collection", "collection.edit.head": "Edit Collection",
"collection.edit.breadcrumbs": "Edit Collection",
"collection.edit.item-mapper.cancel": "Cancel", "collection.edit.item-mapper.cancel": "Cancel",
@@ -450,6 +459,7 @@
"community.edit.head": "Edit Community", "community.edit.head": "Edit Community",
"community.edit.breadcrumbs": "Edit Community",
"community.edit.logo.label": "Community logo", "community.edit.logo.label": "Community logo",
@@ -657,6 +667,8 @@
"item.edit.head": "Edit Item", "item.edit.head": "Edit Item",
"item.edit.breadcrumbs": "Edit Item",
"item.edit.item-mapper.buttons.add": "Map item to selected collections", "item.edit.item-mapper.buttons.add": "Map item to selected collections",
@@ -1081,6 +1093,8 @@
"login.title": "Login", "login.title": "Login",
"login.breadcrumbs": "Login",
"logout.form.header": "Log out from DSpace", "logout.form.header": "Log out from DSpace",
@@ -1477,6 +1491,7 @@
"search.title": "DSpace Angular :: Search", "search.title": "DSpace Angular :: Search",
"search.breadcrumbs": "Search",
"search.filters.applied.f.author": "Author", "search.filters.applied.f.author": "Author",

File diff suppressed because it is too large Load Diff

View File

@@ -230,10 +230,10 @@ describe('BitstreamFormatsComponent', () => {
comp.deleteFormats(); comp.deleteFormats();
expect(bitstreamFormatService.clearBitStreamFormatRequests).toHaveBeenCalled(); expect(bitstreamFormatService.clearBitStreamFormatRequests).toHaveBeenCalled();
expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat1); expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat1.id);
expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat2); expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat2.id);
expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat3); expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat3.id);
expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat4); expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat4.id);
expect(notificationsServiceStub.success).toHaveBeenCalledWith('admin.registries.bitstream-formats.delete.success.head', expect(notificationsServiceStub.success).toHaveBeenCalledWith('admin.registries.bitstream-formats.delete.success.head',
'admin.registries.bitstream-formats.delete.success.amount'); 'admin.registries.bitstream-formats.delete.success.amount');
@@ -276,10 +276,10 @@ describe('BitstreamFormatsComponent', () => {
comp.deleteFormats(); comp.deleteFormats();
expect(bitstreamFormatService.clearBitStreamFormatRequests).toHaveBeenCalled(); expect(bitstreamFormatService.clearBitStreamFormatRequests).toHaveBeenCalled();
expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat1); expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat1.id);
expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat2); expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat2.id);
expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat3); expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat3.id);
expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat4); expect(bitstreamFormatService.delete).toHaveBeenCalledWith(bitstreamFormat4.id);
expect(notificationsServiceStub.error).toHaveBeenCalledWith('admin.registries.bitstream-formats.delete.failure.head', expect(notificationsServiceStub.error).toHaveBeenCalledWith('admin.registries.bitstream-formats.delete.failure.head',
'admin.registries.bitstream-formats.delete.failure.amount'); 'admin.registries.bitstream-formats.delete.failure.amount');

View File

@@ -64,7 +64,7 @@ export class BitstreamFormatsComponent implements OnInit {
const tasks$ = []; const tasks$ = [];
for (const format of formats) { for (const format of formats) {
if (hasValue(format.id)) { if (hasValue(format.id)) {
tasks$.push(this.bitstreamFormatService.delete(format)); tasks$.push(this.bitstreamFormatService.delete(format.id));
} }
} }
zip(...tasks$).subscribe((results: boolean[]) => { zip(...tasks$).subscribe((results: boolean[]) => {

View File

@@ -20,7 +20,6 @@ import { Item } from '../../core/shared/item.model';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { Community } from '../../core/shared/community.model'; import { Community } from '../../core/shared/community.model';
import { MockRouter } from '../../shared/mocks/mock-router'; import { MockRouter } from '../../shared/mocks/mock-router';
import { ResourceType } from '../../core/shared/resource-type';
import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils'; import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils';
import { BrowseEntry } from '../../core/shared/browse-entry.model'; import { BrowseEntry } from '../../core/shared/browse-entry.model';
import { VarDirective } from '../../shared/utils/var.directive'; import { VarDirective } from '../../shared/utils/var.directive';

View File

@@ -0,0 +1,41 @@
import { Injectable } from '@angular/core';
import { Community } from '../core/shared/community.model';
import { DSpaceObjectDataService } from '../core/data/dspace-object-data.service';
import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service';
import { Collection } from '../core/shared/collection.model';
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { BreadcrumbConfig } from '../breadcrumbs/breadcrumb/breadcrumb-config.model';
import { Observable } from 'rxjs';
import { getRemoteDataPayload, getSucceededRemoteData } from '../core/shared/operators';
import { map } from 'rxjs/operators';
import { hasValue } from '../shared/empty.util';
import { getDSOPath } from '../app-routing.module';
/**
* The class that resolves the BreadcrumbConfig object for a DSpaceObject on a browse by page
*/
@Injectable()
export class BrowseByDSOBreadcrumbResolver {
constructor(protected breadcrumbService: DSOBreadcrumbsService, protected dataService: DSpaceObjectDataService) {
}
/**
* Method for resolving a breadcrumb config object
* @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
* @param {RouterStateSnapshot} state The current RouterStateSnapshot
* @returns BreadcrumbConfig object
*/
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<BreadcrumbConfig<Community | Collection>> {
const uuid = route.queryParams.scope;
if (hasValue(uuid)) {
return this.dataService.findById(uuid).pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
map((object: Community | Collection) => {
return { provider: this.breadcrumbService, key: object, url: getDSOPath(object) };
})
);
}
return undefined;
}
}

View File

@@ -37,24 +37,24 @@ export class BrowseByGuard implements CanActivate {
return dsoAndMetadata$.pipe( return dsoAndMetadata$.pipe(
map((dsoRD) => { map((dsoRD) => {
const name = dsoRD.payload.name; const name = dsoRD.payload.name;
route.data = this.createData(title, id, metadataField, name, metadataTranslated, value); route.data = this.createData(title, id, metadataField, name, metadataTranslated, value, route);
return true; return true;
}) })
); );
} else { } else {
route.data = this.createData(title, id, metadataField, '', metadataTranslated, value); route.data = this.createData(title, id, metadataField, '', metadataTranslated, value, route);
return observableOf(true); return observableOf(true);
} }
} }
private createData(title, id, metadataField, collection, field, value) { private createData(title, id, metadataField, collection, field, value, route) {
return { return Object.assign({}, route.data, {
title: title, title: title,
id: id, id: id,
metadataField: metadataField, metadataField: metadataField,
collection: collection, collection: collection,
field: field, field: field,
value: hasValue(value) ? `"${value}"` : '' value: hasValue(value) ? `"${value}"` : ''
} });
} }
} }

View File

@@ -0,0 +1,28 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service';
import { BreadcrumbConfig } from '../breadcrumbs/breadcrumb/breadcrumb-config.model';
import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
/**
* This class resolves a BreadcrumbConfig object with an i18n key string for a route
* It adds the metadata field of the current browse-by page
*/
@Injectable()
export class BrowseByI18nBreadcrumbResolver extends I18nBreadcrumbResolver {
constructor(protected breadcrumbService: I18nBreadcrumbsService) {
super(breadcrumbService);
}
/**
* Method for resolving a browse-by i18n breadcrumb configuration object
* @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
* @param {RouterStateSnapshot} state The current RouterStateSnapshot
* @returns BreadcrumbConfig object for a browse-by page
*/
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): BreadcrumbConfig<string> {
const extendedBreadcrumbKey = route.data.breadcrumbKey + '.' + route.params.id;
route.data = Object.assign({}, route.data, { breadcrumbKey: extendedBreadcrumbKey });
return super.resolve(route, state);
}
}

View File

@@ -2,12 +2,29 @@ import { RouterModule } from '@angular/router';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { BrowseByGuard } from './browse-by-guard'; import { BrowseByGuard } from './browse-by-guard';
import { BrowseBySwitcherComponent } from './+browse-by-switcher/browse-by-switcher.component'; import { BrowseBySwitcherComponent } from './+browse-by-switcher/browse-by-switcher.component';
import { BrowseByDSOBreadcrumbResolver } from './browse-by-dso-breadcrumb.resolver';
import { BrowseByI18nBreadcrumbResolver } from './browse-by-i18n-breadcrumb.resolver';
@NgModule({ @NgModule({
imports: [ imports: [
RouterModule.forChild([ RouterModule.forChild([
{ path: ':id', component: BrowseBySwitcherComponent, canActivate: [BrowseByGuard], data: { title: 'browse.title' } } {
]) path: '',
resolve: { breadcrumb: BrowseByDSOBreadcrumbResolver },
children: [
{
path: ':id',
component: BrowseBySwitcherComponent,
canActivate: [BrowseByGuard],
resolve: { breadcrumb: BrowseByI18nBreadcrumbResolver },
data: { title: 'browse.title', breadcrumbKey: 'browse.metadata' }
}
]
}])
],
providers: [
BrowseByI18nBreadcrumbResolver,
BrowseByDSOBreadcrumbResolver
] ]
}) })
export class BrowseByRoutingModule { export class BrowseByRoutingModule {

View File

@@ -83,7 +83,7 @@ describe('CollectionItemMapperComponent', () => {
const itemDataServiceStub = { const itemDataServiceStub = {
mapToCollection: () => of(new RestResponse(true, 200, 'OK')) mapToCollection: () => of(new RestResponse(true, 200, 'OK'))
}; };
const activatedRouteStub = new ActivatedRouteStub({}, { collection: mockCollectionRD }); const activatedRouteStub = new ActivatedRouteStub({}, { dso: mockCollectionRD });
const translateServiceStub = { const translateServiceStub = {
get: () => of('test-message of collection ' + mockCollection.name), get: () => of('test-message of collection ' + mockCollection.name),
onLangChange: new EventEmitter(), onLangChange: new EventEmitter(),

View File

@@ -102,7 +102,7 @@ export class CollectionItemMapperComponent implements OnInit {
} }
ngOnInit(): void { ngOnInit(): void {
this.collectionRD$ = this.route.data.pipe(map((data) => data.collection)).pipe(getSucceededRemoteData()) as Observable<RemoteData<Collection>>; this.collectionRD$ = this.route.data.pipe(map((data) => data.dso)).pipe(getSucceededRemoteData()) as Observable<RemoteData<Collection>>;
this.searchOptions$ = this.searchConfigService.paginatedSearchOptions; this.searchOptions$ = this.searchConfigService.paginatedSearchOptions;
this.loadItemLists(); this.loadItemLists();
} }

View File

@@ -10,6 +10,9 @@ import { DeleteCollectionPageComponent } from './delete-collection-page/delete-c
import { URLCombiner } from '../core/url-combiner/url-combiner'; import { URLCombiner } from '../core/url-combiner/url-combiner';
import { getCollectionModulePath } from '../app-routing.module'; import { getCollectionModulePath } from '../app-routing.module';
import { CollectionItemMapperComponent } from './collection-item-mapper/collection-item-mapper.component'; import { CollectionItemMapperComponent } from './collection-item-mapper/collection-item-mapper.component';
import { CollectionBreadcrumbResolver } from '../core/breadcrumbs/collection-breadcrumb.resolver';
import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service';
import { LinkService } from '../core/cache/builders/link.service';
export const COLLECTION_PARENT_PARAMETER = 'parent'; export const COLLECTION_PARENT_PARAMETER = 'parent';
@@ -18,7 +21,7 @@ export function getCollectionPageRoute(collectionId: string) {
} }
export function getCollectionEditPath(id: string) { export function getCollectionEditPath(id: string) {
return new URLCombiner(getCollectionModulePath(), COLLECTION_EDIT_PATH.replace(/:id/, id)).toString() return new URLCombiner(getCollectionModulePath(), id, COLLECTION_EDIT_PATH).toString()
} }
export function getCollectionCreatePath() { export function getCollectionCreatePath() {
@@ -26,51 +29,54 @@ export function getCollectionCreatePath() {
} }
const COLLECTION_CREATE_PATH = 'create'; const COLLECTION_CREATE_PATH = 'create';
const COLLECTION_EDIT_PATH = ':id/edit'; const COLLECTION_EDIT_PATH = 'edit';
@NgModule({ @NgModule({
imports: [ imports: [
RouterModule.forChild([ RouterModule.forChild([
{
path: ':id',
resolve: {
dso: CollectionPageResolver,
breadcrumb: CollectionBreadcrumbResolver
},
children: [
{
path: COLLECTION_EDIT_PATH,
loadChildren: './edit-collection-page/edit-collection-page.module#EditCollectionPageModule',
canActivate: [AuthenticatedGuard]
},
{
path: 'delete',
pathMatch: 'full',
component: DeleteCollectionPageComponent,
canActivate: [AuthenticatedGuard],
},
{
path: '',
component: CollectionPageComponent,
pathMatch: 'full',
},
{
path: '/edit/mapper',
component: CollectionItemMapperComponent,
pathMatch: 'full',
canActivate: [AuthenticatedGuard]
}
]
},
{ {
path: COLLECTION_CREATE_PATH, path: COLLECTION_CREATE_PATH,
component: CreateCollectionPageComponent, component: CreateCollectionPageComponent,
canActivate: [AuthenticatedGuard, CreateCollectionPageGuard] canActivate: [AuthenticatedGuard, CreateCollectionPageGuard]
}, },
{
path: COLLECTION_EDIT_PATH,
loadChildren: './edit-collection-page/edit-collection-page.module#EditCollectionPageModule',
canActivate: [AuthenticatedGuard]
},
{
path: ':id/delete',
pathMatch: 'full',
component: DeleteCollectionPageComponent,
canActivate: [AuthenticatedGuard],
resolve: {
dso: CollectionPageResolver
}
},
{
path: ':id',
component: CollectionPageComponent,
pathMatch: 'full',
resolve: {
collection: CollectionPageResolver
}
},
{
path: ':id/edit/mapper',
component: CollectionItemMapperComponent,
pathMatch: 'full',
resolve: {
collection: CollectionPageResolver
},
canActivate: [AuthenticatedGuard]
}
]) ])
], ],
providers: [ providers: [
CollectionPageResolver, CollectionPageResolver,
CollectionBreadcrumbResolver,
DSOBreadcrumbsService,
LinkService,
CreateCollectionPageGuard CreateCollectionPageGuard
] ]
}) })

View File

@@ -62,7 +62,7 @@ export class CollectionPageComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.collectionRD$ = this.route.data.pipe( this.collectionRD$ = this.route.data.pipe(
map((data) => data.collection as RemoteData<Collection>), map((data) => data.dso as RemoteData<Collection>),
redirectToPageNotFoundOn404(this.router), redirectToPageNotFoundOn404(this.router),
take(1) take(1)
); );

View File

@@ -1,11 +1,11 @@
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { EditCollectionPageComponent } from './edit-collection-page.component'; import { EditCollectionPageComponent } from './edit-collection-page.component';
import { CollectionPageResolver } from '../collection-page.resolver';
import { CollectionMetadataComponent } from './collection-metadata/collection-metadata.component'; import { CollectionMetadataComponent } from './collection-metadata/collection-metadata.component';
import { CollectionRolesComponent } from './collection-roles/collection-roles.component'; import { CollectionRolesComponent } from './collection-roles/collection-roles.component';
import { CollectionSourceComponent } from './collection-source/collection-source.component'; import { CollectionSourceComponent } from './collection-source/collection-source.component';
import { CollectionCurateComponent } from './collection-curate/collection-curate.component'; import { CollectionCurateComponent } from './collection-curate/collection-curate.component';
import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver';
/** /**
* Routing module that handles the routing for the Edit Collection page administrator functionality * Routing module that handles the routing for the Edit Collection page administrator functionality
@@ -15,10 +15,11 @@ import { CollectionCurateComponent } from './collection-curate/collection-curate
RouterModule.forChild([ RouterModule.forChild([
{ {
path: '', path: '',
component: EditCollectionPageComponent,
resolve: { resolve: {
dso: CollectionPageResolver breadcrumb: I18nBreadcrumbResolver
}, },
data: { breadcrumbKey: 'collection.edit' },
component: EditCollectionPageComponent,
children: [ children: [
{ {
path: '', path: '',
@@ -30,30 +31,28 @@ import { CollectionCurateComponent } from './collection-curate/collection-curate
component: CollectionMetadataComponent, component: CollectionMetadataComponent,
data: { data: {
title: 'collection.edit.tabs.metadata.title', title: 'collection.edit.tabs.metadata.title',
hideReturnButton: true hideReturnButton: true,
showBreadcrumbs: true
} }
}, },
{ {
path: 'roles', path: 'roles',
component: CollectionRolesComponent, component: CollectionRolesComponent,
data: { title: 'collection.edit.tabs.roles.title' } data: { title: 'collection.edit.tabs.roles.title', showBreadcrumbs: true }
}, },
{ {
path: 'source', path: 'source',
component: CollectionSourceComponent, component: CollectionSourceComponent,
data: { title: 'collection.edit.tabs.source.title' } data: { title: 'collection.edit.tabs.source.title', showBreadcrumbs: true }
}, },
{ {
path: 'curate', path: 'curate',
component: CollectionCurateComponent, component: CollectionCurateComponent,
data: { title: 'collection.edit.tabs.curate.title' } data: { title: 'collection.edit.tabs.curate.title', showBreadcrumbs: true }
} }
] ]
} }
]) ])
],
providers: [
CollectionPageResolver,
] ]
}) })
export class EditCollectionPageRoutingModule { export class EditCollectionPageRoutingModule {

View File

@@ -9,6 +9,9 @@ import { CreateCommunityPageGuard } from './create-community-page/create-communi
import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component'; import { DeleteCommunityPageComponent } from './delete-community-page/delete-community-page.component';
import { URLCombiner } from '../core/url-combiner/url-combiner'; import { URLCombiner } from '../core/url-combiner/url-combiner';
import { getCommunityModulePath } from '../app-routing.module'; import { getCommunityModulePath } from '../app-routing.module';
import { CommunityBreadcrumbResolver } from '../core/breadcrumbs/community-breadcrumb.resolver';
import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service';
import { LinkService } from '../core/cache/builders/link.service';
export const COMMUNITY_PARENT_PARAMETER = 'parent'; export const COMMUNITY_PARENT_PARAMETER = 'parent';
@@ -17,7 +20,7 @@ export function getCommunityPageRoute(communityId: string) {
} }
export function getCommunityEditPath(id: string) { export function getCommunityEditPath(id: string) {
return new URLCombiner(getCommunityModulePath(), COMMUNITY_EDIT_PATH.replace(/:id/, id)).toString() return new URLCombiner(getCommunityModulePath(), id, COMMUNITY_EDIT_PATH).toString()
} }
export function getCommunityCreatePath() { export function getCommunityCreatePath() {
@@ -25,42 +28,48 @@ export function getCommunityCreatePath() {
} }
const COMMUNITY_CREATE_PATH = 'create'; const COMMUNITY_CREATE_PATH = 'create';
const COMMUNITY_EDIT_PATH = ':id/edit'; const COMMUNITY_EDIT_PATH = 'edit';
@NgModule({ @NgModule({
imports: [ imports: [
RouterModule.forChild([ RouterModule.forChild([
{
path: ':id',
resolve: {
dso: CommunityPageResolver,
breadcrumb: CommunityBreadcrumbResolver
},
children: [
{
path: COMMUNITY_EDIT_PATH,
loadChildren: './edit-community-page/edit-community-page.module#EditCommunityPageModule',
canActivate: [AuthenticatedGuard]
},
{
path: 'delete',
pathMatch: 'full',
component: DeleteCommunityPageComponent,
canActivate: [AuthenticatedGuard],
},
{
path: '',
component: CommunityPageComponent,
pathMatch: 'full',
}
]
},
{ {
path: COMMUNITY_CREATE_PATH, path: COMMUNITY_CREATE_PATH,
component: CreateCommunityPageComponent, component: CreateCommunityPageComponent,
canActivate: [AuthenticatedGuard, CreateCommunityPageGuard] canActivate: [AuthenticatedGuard, CreateCommunityPageGuard]
}, },
{
path: COMMUNITY_EDIT_PATH,
loadChildren: './edit-community-page/edit-community-page.module#EditCommunityPageModule',
canActivate: [AuthenticatedGuard]
},
{
path: ':id/delete',
pathMatch: 'full',
component: DeleteCommunityPageComponent,
canActivate: [AuthenticatedGuard],
resolve: {
dso: CommunityPageResolver
}
},
{
path: ':id',
component: CommunityPageComponent,
pathMatch: 'full',
resolve: {
community: CommunityPageResolver
}
}
]) ])
], ],
providers: [ providers: [
CommunityPageResolver, CommunityPageResolver,
CommunityBreadcrumbResolver,
DSOBreadcrumbsService,
LinkService,
CreateCommunityPageGuard CreateCommunityPageGuard
] ]
}) })

View File

@@ -46,7 +46,7 @@ export class CommunityPageComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.communityRD$ = this.route.data.pipe( this.communityRD$ = this.route.data.pipe(
map((data) => data.community as RemoteData<Community>), map((data) => data.dso as RemoteData<Community>),
redirectToPageNotFoundOn404(this.router) redirectToPageNotFoundOn404(this.router)
); );
this.logoRD$ = this.communityRD$.pipe( this.logoRD$ = this.communityRD$.pipe(

View File

@@ -5,6 +5,7 @@ import { NgModule } from '@angular/core';
import { CommunityMetadataComponent } from './community-metadata/community-metadata.component'; import { CommunityMetadataComponent } from './community-metadata/community-metadata.component';
import { CommunityRolesComponent } from './community-roles/community-roles.component'; import { CommunityRolesComponent } from './community-roles/community-roles.component';
import { CommunityCurateComponent } from './community-curate/community-curate.component'; import { CommunityCurateComponent } from './community-curate/community-curate.component';
import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver';
/** /**
* Routing module that handles the routing for the Edit Community page administrator functionality * Routing module that handles the routing for the Edit Community page administrator functionality
@@ -14,10 +15,11 @@ import { CommunityCurateComponent } from './community-curate/community-curate.co
RouterModule.forChild([ RouterModule.forChild([
{ {
path: '', path: '',
component: EditCommunityPageComponent,
resolve: { resolve: {
dso: CommunityPageResolver breadcrumb: I18nBreadcrumbResolver
}, },
data: { breadcrumbKey: 'community.edit' },
component: EditCommunityPageComponent,
children: [ children: [
{ {
path: '', path: '',
@@ -29,26 +31,24 @@ import { CommunityCurateComponent } from './community-curate/community-curate.co
component: CommunityMetadataComponent, component: CommunityMetadataComponent,
data: { data: {
title: 'community.edit.tabs.metadata.title', title: 'community.edit.tabs.metadata.title',
hideReturnButton: true hideReturnButton: true,
showBreadcrumbs: true
} }
}, },
{ {
path: 'roles', path: 'roles',
component: CommunityRolesComponent, component: CommunityRolesComponent,
data: { title: 'community.edit.tabs.roles.title' } data: { title: 'community.edit.tabs.roles.title', showBreadcrumbs: true }
}, },
{ {
path: 'curate', path: 'curate',
component: CommunityCurateComponent, component: CommunityCurateComponent,
data: { title: 'community.edit.tabs.curate.title' } data: { title: 'community.edit.tabs.curate.title', showBreadcrumbs: true }
} }
] ]
} }
]) ])
], ],
providers: [
CommunityPageResolver,
]
}) })
export class EditCommunityPageRoutingModule { export class EditCommunityPageRoutingModule {

View File

@@ -13,6 +13,7 @@ import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.compo
import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component'; import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component';
import { ItemMoveComponent } from './item-move/item-move.component'; import { ItemMoveComponent } from './item-move/item-move.component';
import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component'; import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component';
import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver';
const ITEM_EDIT_WITHDRAW_PATH = 'withdraw'; const ITEM_EDIT_WITHDRAW_PATH = 'withdraw';
const ITEM_EDIT_REINSTATE_PATH = 'reinstate'; const ITEM_EDIT_REINSTATE_PATH = 'reinstate';
@@ -29,104 +30,88 @@ const ITEM_EDIT_MOVE_PATH = 'move';
RouterModule.forChild([ RouterModule.forChild([
{ {
path: '', path: '',
component: EditItemPageComponent,
resolve: { resolve: {
item: ItemPageResolver breadcrumb: I18nBreadcrumbResolver
}, },
data: { breadcrumbKey: 'item.edit' },
children: [ children: [
{ {
path: '', path: '',
redirectTo: 'status', component: EditItemPageComponent,
pathMatch: 'full' children: [
{
path: '',
redirectTo: 'status',
pathMatch: 'full'
},
{
path: 'status',
component: ItemStatusComponent,
data: { title: 'item.edit.tabs.status.title', showBreadcrumbs: true }
},
{
path: 'bitstreams',
component: ItemBitstreamsComponent,
data: { title: 'item.edit.tabs.bitstreams.title', showBreadcrumbs: true }
},
{
path: 'metadata',
component: ItemMetadataComponent,
data: { title: 'item.edit.tabs.metadata.title', showBreadcrumbs: true }
},
{
path: 'relationships',
component: ItemRelationshipsComponent,
data: { title: 'item.edit.tabs.relationships.title', showBreadcrumbs: true }
},
{
path: 'view',
/* TODO - change when view page exists */
component: ItemBitstreamsComponent,
data: { title: 'item.edit.tabs.view.title', showBreadcrumbs: true }
},
{
path: 'curate',
/* TODO - change when curate page exists */
component: ItemBitstreamsComponent,
data: { title: 'item.edit.tabs.curate.title', showBreadcrumbs: true }
}
]
}, },
{ {
path: 'status', path: 'mapper',
component: ItemStatusComponent, component: ItemCollectionMapperComponent,
data: { title: 'item.edit.tabs.status.title' }
}, },
{ {
path: 'bitstreams', path: ITEM_EDIT_WITHDRAW_PATH,
component: ItemBitstreamsComponent, component: ItemWithdrawComponent,
data: { title: 'item.edit.tabs.bitstreams.title' }
}, },
{ {
path: 'metadata', path: ITEM_EDIT_REINSTATE_PATH,
component: ItemMetadataComponent, component: ItemReinstateComponent,
data: { title: 'item.edit.tabs.metadata.title' }
}, },
{ {
path: 'relationships', path: ITEM_EDIT_PRIVATE_PATH,
component: ItemRelationshipsComponent, component: ItemPrivateComponent,
data: { title: 'item.edit.tabs.relationships.title' }
}, },
{ {
path: 'view', path: ITEM_EDIT_PUBLIC_PATH,
/* TODO - change when view page exists */ component: ItemPublicComponent,
component: ItemBitstreamsComponent,
data: { title: 'item.edit.tabs.view.title' }
}, },
{ {
path: 'curate', path: ITEM_EDIT_DELETE_PATH,
/* TODO - change when curate page exists */ component: ItemDeleteComponent,
component: ItemBitstreamsComponent,
data: { title: 'item.edit.tabs.curate.title' }
}, },
{
path: ITEM_EDIT_MOVE_PATH,
component: ItemMoveComponent,
data: { title: 'item.edit.move.title' },
}
] ]
}, }
{ ])
path: 'mapper',
component: ItemCollectionMapperComponent,
resolve: {
item: ItemPageResolver
}
},
{
path: ITEM_EDIT_WITHDRAW_PATH,
component: ItemWithdrawComponent,
resolve: {
item: ItemPageResolver
}
},
{
path: ITEM_EDIT_REINSTATE_PATH,
component: ItemReinstateComponent,
resolve: {
item: ItemPageResolver
}
},
{
path: ITEM_EDIT_PRIVATE_PATH,
component: ItemPrivateComponent,
resolve: {
item: ItemPageResolver
}
},
{
path: ITEM_EDIT_PUBLIC_PATH,
component: ItemPublicComponent,
resolve: {
item: ItemPageResolver
}
},
{
path: ITEM_EDIT_DELETE_PATH,
component: ItemDeleteComponent,
resolve: {
item: ItemPageResolver
}
},
{
path: ITEM_EDIT_MOVE_PATH,
component: ItemMoveComponent,
data: { title: 'item.edit.move.title' },
resolve: {
item: ItemPageResolver
}
}])
], ],
providers: [ providers: []
ItemPageResolver,
]
}) })
export class EditItemPageRoutingModule { export class EditItemPageRoutingModule {

View File

@@ -220,7 +220,7 @@ describe('ItemDeleteComponent', () => {
spyOn(comp, 'notify'); spyOn(comp, 'notify');
comp.performAction(); comp.performAction();
expect(mockItemDataService.delete) expect(mockItemDataService.delete)
.toHaveBeenCalledWith(mockItem, types.filter((type) => typesSelection[type]).map((type) => type.id)); .toHaveBeenCalledWith(mockItem.id, types.filter((type) => typesSelection[type]).map((type) => type.id));
expect(comp.notify).toHaveBeenCalled(); expect(comp.notify).toHaveBeenCalled();
}); });
}); });

View File

@@ -312,7 +312,7 @@ export class ItemDeleteComponent
) )
), ),
).subscribe((types) => { ).subscribe((types) => {
this.itemDataService.delete(this.item, types).pipe(first()).subscribe( this.itemDataService.delete(this.item.id, types).pipe(first()).subscribe(
(succeeded: boolean) => { (succeeded: boolean) => {
this.notify(succeeded); this.notify(succeeded);
} }
@@ -322,7 +322,7 @@ export class ItemDeleteComponent
/** /**
* When the item is successfully delete, navigate to the homepage, otherwise navigate back to the item edit page * When the item is successfully delete, navigate to the homepage, otherwise navigate back to the item edit page
* @param response * @param succeeded
*/ */
notify(succeeded: boolean) { notify(succeeded: boolean) {
if (succeeded) { if (succeeded) {

View File

@@ -7,44 +7,55 @@ import { ItemPageResolver } from './item-page.resolver';
import { URLCombiner } from '../core/url-combiner/url-combiner'; import { URLCombiner } from '../core/url-combiner/url-combiner';
import { getItemModulePath } from '../app-routing.module'; import { getItemModulePath } from '../app-routing.module';
import { AuthenticatedGuard } from '../core/auth/authenticated.guard'; import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
import { ItemBreadcrumbResolver } from '../core/breadcrumbs/item-breadcrumb.resolver';
import { DSOBreadcrumbsService } from '../core/breadcrumbs/dso-breadcrumbs.service';
import { LinkService } from '../core/cache/builders/link.service';
export function getItemPageRoute(itemId: string) { export function getItemPageRoute(itemId: string) {
return new URLCombiner(getItemModulePath(), itemId).toString(); return new URLCombiner(getItemModulePath(), itemId).toString();
} }
export function getItemEditPath(id: string) { export function getItemEditPath(id: string) {
return new URLCombiner(getItemModulePath(),ITEM_EDIT_PATH.replace(/:id/, id)).toString() return new URLCombiner(getItemModulePath(), id, ITEM_EDIT_PATH).toString()
} }
const ITEM_EDIT_PATH = ':id/edit'; const ITEM_EDIT_PATH = 'edit';
@NgModule({ @NgModule({
imports: [ imports: [
RouterModule.forChild([ RouterModule.forChild([
{ {
path: ':id', path: ':id',
component: ItemPageComponent,
pathMatch: 'full',
resolve: { resolve: {
item: ItemPageResolver item: ItemPageResolver,
} breadcrumb: ItemBreadcrumbResolver
}, },
{ children: [
path: ':id/full', {
component: FullItemPageComponent, path: '',
resolve: { component: ItemPageComponent,
item: ItemPageResolver pathMatch: 'full',
} },
}, {
{ path: 'full',
path: ITEM_EDIT_PATH, component: FullItemPageComponent,
loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule', },
canActivate: [AuthenticatedGuard] {
}, path: ITEM_EDIT_PATH,
loadChildren: './edit-item-page/edit-item-page.module#EditItemPageModule',
canActivate: [AuthenticatedGuard]
}
],
}
]) ])
], ],
providers: [ providers: [
ItemPageResolver, ItemPageResolver,
ItemBreadcrumbResolver,
DSOBreadcrumbsService,
LinkService
] ]
}) })
export class ItemPageRoutingModule { export class ItemPageRoutingModule {

View File

@@ -27,7 +27,7 @@ export class ItemPageResolver implements Resolve<RemoteData<Item>> {
return this.itemService.findById(route.params.id, return this.itemService.findById(route.params.id,
followLink('owningCollection'), followLink('owningCollection'),
followLink('bundles'), followLink('bundles'),
followLink('relationships') followLink('relationships'),
).pipe( ).pipe(
find((RD) => hasValue(RD.error) || RD.hasSucceeded), find((RD) => hasValue(RD.error) || RD.hasSucceeded),
); );

View File

@@ -2,12 +2,14 @@ import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { LoginPageComponent } from './login-page.component'; import { LoginPageComponent } from './login-page.component';
import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
@NgModule({ @NgModule({
imports: [ imports: [
RouterModule.forChild([ RouterModule.forChild([
{ path: '', pathMatch: 'full', component: LoginPageComponent, data: { title: 'login.title' } } { path: '', pathMatch: 'full', component: LoginPageComponent, resolve: { breadcrumb: I18nBreadcrumbResolver }, data: { breadcrumbKey: 'login', title: 'login.title' } }
]) ])
] ]
}) })
export class LoginPageRoutingModule { } export class LoginPageRoutingModule {
}

View File

@@ -19,7 +19,7 @@
<ds-search-labels [inPlaceSearch]="inPlaceSearch"></ds-search-labels> <ds-search-labels [inPlaceSearch]="inPlaceSearch"></ds-search-labels>
<div class="row"> <div class="row">
<div id="search-body" <div id="search-body"
class="row-offcanvas row-offcanvas-left" class="row-offcanvas row-offcanvas-left w-100"
[@pushInOut]="(isSidebarCollapsed() | async) ? 'collapsed' : 'expanded'"> [@pushInOut]="(isSidebarCollapsed() | async) ? 'collapsed' : 'expanded'">
<ds-search-sidebar *ngIf="(isXsOrSm$ | async)" class="col-12" <ds-search-sidebar *ngIf="(isXsOrSm$ | async)" class="col-12"
id="search-sidebar-sm" id="search-sidebar-sm"

View File

@@ -4,13 +4,24 @@ import { RouterModule } from '@angular/router';
import { ConfigurationSearchPageGuard } from './configuration-search-page.guard'; import { ConfigurationSearchPageGuard } from './configuration-search-page.guard';
import { ConfigurationSearchPageComponent } from './configuration-search-page.component'; import { ConfigurationSearchPageComponent } from './configuration-search-page.component';
import { SearchPageComponent } from './search-page.component'; import { SearchPageComponent } from './search-page.component';
import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.service';
@NgModule({ @NgModule({
imports: [ imports: [
RouterModule.forChild([ RouterModule.forChild([{
{ path: '', component: SearchPageComponent, data: { title: 'search.title' } }, path: '',
{ path: ':configuration', component: ConfigurationSearchPageComponent, canActivate: [ConfigurationSearchPageGuard]} resolve: { breadcrumb: I18nBreadcrumbResolver }, data: { title: 'search.title', breadcrumbKey: 'search' },
]) children: [
{ path: '', component: SearchPageComponent },
{ path: ':configuration', component: ConfigurationSearchPageComponent, canActivate: [ConfigurationSearchPageGuard] }
]
}]
)
],
providers: [
I18nBreadcrumbResolver,
I18nBreadcrumbsService
] ]
}) })
export class SearchPageRoutingModule { export class SearchPageRoutingModule {

View File

@@ -3,6 +3,15 @@ import { RouterModule } from '@angular/router';
import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component'; import { PageNotFoundComponent } from './pagenotfound/pagenotfound.component';
import { AuthenticatedGuard } from './core/auth/authenticated.guard'; import { AuthenticatedGuard } from './core/auth/authenticated.guard';
import { Breadcrumb } from './breadcrumbs/breadcrumb/breadcrumb.model';
import { DSpaceObject } from './core/shared/dspace-object.model';
import { Community } from './core/shared/community.model';
import { getCommunityPageRoute } from './+community-page/community-page-routing.module';
import { Collection } from './core/shared/collection.model';
import { Item } from './core/shared/item.model';
import { getItemPageRoute } from './+item-page/item-page-routing.module';
import { getCollectionPageRoute } from './+collection-page/collection-page-routing.module';
import { BrowseByDSOBreadcrumbResolver } from './+browse-by/browse-by-dso-breadcrumb.resolver';
const ITEM_MODULE_PATH = 'items'; const ITEM_MODULE_PATH = 'items';
@@ -28,11 +37,22 @@ export function getAdminModulePath() {
return `/${ADMIN_MODULE_PATH}`; return `/${ADMIN_MODULE_PATH}`;
} }
export function getDSOPath(dso: DSpaceObject): string {
switch ((dso as any).type) {
case Community.type.value:
return getCommunityPageRoute(dso.uuid);
case Collection.type.value:
return getCollectionPageRoute(dso.uuid);
case Item.type.value:
return getItemPageRoute(dso.uuid);
}
}
@NgModule({ @NgModule({
imports: [ imports: [
RouterModule.forRoot([ RouterModule.forRoot([
{ path: '', redirectTo: '/home', pathMatch: 'full' }, { path: '', redirectTo: '/home', pathMatch: 'full' },
{ path: 'home', loadChildren: './+home-page/home-page.module#HomePageModule' }, { path: 'home', loadChildren: './+home-page/home-page.module#HomePageModule', data: { showBreadcrumbs: false } },
{ path: 'community-list', loadChildren: './community-list-page/community-list-page.module#CommunityListPageModule' }, { path: 'community-list', loadChildren: './community-list-page/community-list-page.module#CommunityListPageModule' },
{ path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' }, { path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' },
{ path: 'handle', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' }, { path: 'handle', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' },
@@ -45,7 +65,7 @@ export function getAdminModulePath() {
canActivate: [AuthenticatedGuard] canActivate: [AuthenticatedGuard]
}, },
{ path: 'search', loadChildren: './+search-page/search-page.module#SearchPageModule' }, { path: 'search', loadChildren: './+search-page/search-page.module#SearchPageModule' },
{ path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule' }, { path: 'browse', loadChildren: './+browse-by/browse-by.module#BrowseByModule'},
{ path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [AuthenticatedGuard] }, { path: ADMIN_MODULE_PATH, loadChildren: './+admin/admin.module#AdminModule', canActivate: [AuthenticatedGuard] },
{ path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' }, { path: 'login', loadChildren: './+login-page/login-page.module#LoginPageModule' },
{ path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' }, { path: 'logout', loadChildren: './+logout-page/logout-page.module#LogoutPageModule' },
@@ -61,7 +81,7 @@ export function getAdminModulePath() {
{ path: '**', pathMatch: 'full', component: PageNotFoundComponent }, { path: '**', pathMatch: 'full', component: PageNotFoundComponent },
]) ])
], ],
exports: [RouterModule] exports: [RouterModule],
}) })
export class AppRoutingModule { export class AppRoutingModule {

View File

@@ -10,6 +10,10 @@
[options]="config.notifications"> [options]="config.notifications">
</ds-notifications-board> </ds-notifications-board>
<main class="main-content"> <main class="main-content">
<div class="container">
<ds-breadcrumbs></ds-breadcrumbs>
</div>
<div class="container" *ngIf="isLoading$ | async"> <div class="container" *ngIf="isLoading$ | async">
<ds-loading message="{{'loading.default' | translate}}"></ds-loading> <ds-loading message="{{'loading.default' | translate}}"></ds-loading>
</div> </div>

View File

@@ -3,12 +3,11 @@ import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
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 { META_REDUCERS, MetaReducer, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store'; import { MetaReducer, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools'; import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { DYNAMIC_MATCHER_PROVIDERS } 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';
@@ -21,7 +20,7 @@ import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
import { appEffects } from './app.effects'; import { appEffects } from './app.effects';
import { appMetaReducers, debugMetaReducers, universalMetaReducer } from './app.metareducers'; import { appMetaReducers, debugMetaReducers } from './app.metareducers';
import { appReducers, AppState } from './app.reducer'; import { appReducers, AppState } from './app.reducer';
import { CoreModule } from './core/core.module'; import { CoreModule } from './core/core.module';
@@ -39,6 +38,7 @@ import { DSpaceRouterStateSerializer } from './shared/ngrx/dspace-router-state-s
import { NotificationComponent } from './shared/notifications/notification/notification.component'; import { NotificationComponent } from './shared/notifications/notification/notification.component';
import { NotificationsBoardComponent } from './shared/notifications/notifications-board/notifications-board.component'; import { NotificationsBoardComponent } from './shared/notifications/notifications-board/notifications-board.component';
import { SharedModule } from './shared/shared.module'; import { SharedModule } from './shared/shared.module';
import { BreadcrumbsComponent } from './breadcrumbs/breadcrumbs.component';
export function getConfig() { export function getConfig() {
return ENV_CONFIG; return ENV_CONFIG;
@@ -97,7 +97,8 @@ const PROVIDERS = [
provide: RouterStateSerializer, provide: RouterStateSerializer,
useClass: DSpaceRouterStateSerializer useClass: DSpaceRouterStateSerializer
}, },
ClientCookieService ClientCookieService,
...DYNAMIC_MATCHER_PROVIDERS,
]; ];
const DECLARATIONS = [ const DECLARATIONS = [
@@ -128,6 +129,7 @@ const EXPORTS = [
], ],
declarations: [ declarations: [
...DECLARATIONS, ...DECLARATIONS,
BreadcrumbsComponent,
], ],
exports: [ exports: [
...EXPORTS ...EXPORTS

View File

@@ -0,0 +1,21 @@
import { BreadcrumbsService } from '../../core/breadcrumbs/breadcrumbs.service';
/**
* Interface for breadcrumb configuration objects
*/
export interface BreadcrumbConfig<T> {
/**
* The service used to calculate the breadcrumb object
*/
provider: BreadcrumbsService<T>;
/**
* The key that is used to calculate the breadcrumb display value
*/
key: T;
/**
* The url of the breadcrumb
*/
url?: string;
}

View File

@@ -0,0 +1,15 @@
/**
* Class representing a single breadcrumb
*/
export class Breadcrumb {
constructor(
/**
* The display value of the breadcrumb
*/
public text: string,
/**
* The optional url of the breadcrumb
*/
public url?: string) {
}
}

View File

@@ -0,0 +1,17 @@
<nav *ngIf="showBreadcrumbs" aria-label="breadcrumb">
<ol class="breadcrumb">
<ng-container *ngTemplateOutlet="breadcrumbs.length > 0 ? breadcrumb : activeBreadcrumb; context: {text: 'Home', url: '/'}"></ng-container>
<ng-container *ngFor="let bc of breadcrumbs; let last = last;">
<ng-container *ngTemplateOutlet="!last ? breadcrumb : activeBreadcrumb; context: bc"></ng-container>
</ng-container>
</ol>
</nav>
<ng-template #breadcrumb let-text="text" let-url="url">
<li class="breadcrumb-item"><a [routerLink]="url">{{text | translate}}</a></li>
</ng-template>
<ng-template #activeBreadcrumb let-text="text" >
<li class="breadcrumb-item active" aria-current="page">{{text | translate}}</li>
</ng-template>

View File

@@ -0,0 +1,111 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { BreadcrumbsComponent } from './breadcrumbs.component';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { Observable, of as observableOf } from 'rxjs';
import { RouterTestingModule } from '@angular/router/testing';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { MockTranslateLoader } from '../shared/testing/mock-translate-loader';
import { BreadcrumbConfig } from './breadcrumb/breadcrumb-config.model';
import { BreadcrumbsService } from '../core/breadcrumbs/breadcrumbs.service';
import { Breadcrumb } from './breadcrumb/breadcrumb.model';
import { getTestScheduler } from 'jasmine-marbles';
class TestBreadcrumbsService implements BreadcrumbsService<string> {
getBreadcrumbs(key: string, url: string): Observable<Breadcrumb[]> {
return observableOf([new Breadcrumb(key, url)]);
}
}
describe('BreadcrumbsComponent', () => {
let component: BreadcrumbsComponent;
let fixture: ComponentFixture<BreadcrumbsComponent>;
let router: any;
let route: any;
let breadcrumbProvider;
let breadcrumbConfigA: BreadcrumbConfig<string>;
let breadcrumbConfigB: BreadcrumbConfig<string>;
let expectedBreadcrumbs;
function init() {
breadcrumbProvider = new TestBreadcrumbsService();
breadcrumbConfigA = { provider: breadcrumbProvider, key: 'example.path', url: 'example.com' };
breadcrumbConfigB = { provider: breadcrumbProvider, key: 'another.path', url: 'another.com' };
route = {
root: {
snapshot: {
data: { breadcrumb: breadcrumbConfigA },
routeConfig: { resolve: { breadcrumb: {} } }
},
firstChild: {
snapshot: {
// Example without resolver should be ignored
data: { breadcrumb: breadcrumbConfigA },
},
firstChild: {
snapshot: {
data: { breadcrumb: breadcrumbConfigB },
routeConfig: { resolve: { breadcrumb: {} } }
}
}
}
}
};
expectedBreadcrumbs = [
new Breadcrumb(breadcrumbConfigA.key, breadcrumbConfigA.url),
new Breadcrumb(breadcrumbConfigB.key, breadcrumbConfigB.url)
]
}
beforeEach(async(() => {
init();
TestBed.configureTestingModule({
declarations: [BreadcrumbsComponent],
imports: [RouterTestingModule.withRoutes([]), TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: MockTranslateLoader
}
})],
providers: [
{ provide: ActivatedRoute, useValue: route }
]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(BreadcrumbsComponent);
component = fixture.componentInstance;
router = TestBed.get(Router);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('ngOnInit', () => {
beforeEach(() => {
spyOn(component, 'resolveBreadcrumbs').and.returnValue(observableOf([]))
});
it('should call resolveBreadcrumb on init', () => {
router.events = observableOf(new NavigationEnd(0, '', ''));
component.ngOnInit();
expect(component.resolveBreadcrumbs).toHaveBeenCalledWith(route.root);
})
});
describe('resolveBreadcrumbs', () => {
it('should return the correct breadcrumbs', () => {
const breadcrumbs = component.resolveBreadcrumbs(route.root);
getTestScheduler().expectObservable(breadcrumbs).toBe('(a|)', { a: expectedBreadcrumbs })
})
})
});

View File

@@ -0,0 +1,100 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { Breadcrumb } from './breadcrumb/breadcrumb.model';
import { hasNoValue, hasValue, isNotUndefined, isUndefined } from '../shared/empty.util';
import { filter, map, switchMap, tap } from 'rxjs/operators';
import { combineLatest, Observable, Subscription, of as observableOf } from 'rxjs';
/**
* Component representing the breadcrumbs of a page
*/
@Component({
selector: 'ds-breadcrumbs',
templateUrl: './breadcrumbs.component.html',
styleUrls: ['./breadcrumbs.component.scss']
})
export class BreadcrumbsComponent implements OnInit, OnDestroy {
/**
* List of breadcrumbs for this page
*/
breadcrumbs: Breadcrumb[];
/**
* Whether or not to show breadcrumbs on this page
*/
showBreadcrumbs: boolean;
/**
* Subscription to unsubscribe from on destroy
*/
subscription: Subscription;
constructor(
private route: ActivatedRoute,
private router: Router
) {
}
/**
* Sets the breadcrumbs on init for this page
*/
ngOnInit(): void {
this.subscription = this.router.events.pipe(
filter((e): e is NavigationEnd => e instanceof NavigationEnd),
tap(() => this.reset()),
switchMap(() => this.resolveBreadcrumbs(this.route.root))
).subscribe((breadcrumbs) => {
this.breadcrumbs = breadcrumbs;
}
)
}
/**
* Method that recursively resolves breadcrumbs
* @param route The route to get the breadcrumb from
*/
resolveBreadcrumbs(route: ActivatedRoute): Observable<Breadcrumb[]> {
const data = route.snapshot.data;
const routeConfig = route.snapshot.routeConfig;
const last: boolean = hasNoValue(route.firstChild);
if (last) {
if (hasValue(data.showBreadcrumbs)) {
this.showBreadcrumbs = data.showBreadcrumbs;
} else if (isUndefined(data.breadcrumb)) {
this.showBreadcrumbs = false;
}
}
if (
hasValue(data) && hasValue(data.breadcrumb) &&
hasValue(routeConfig) && hasValue(routeConfig.resolve) && hasValue(routeConfig.resolve.breadcrumb)
) {
const { provider, key, url } = data.breadcrumb;
if (!last) {
return combineLatest(provider.getBreadcrumbs(key, url), this.resolveBreadcrumbs(route.firstChild))
.pipe(map((crumbs) => [].concat.apply([], crumbs)));
} else {
return provider.getBreadcrumbs(key, url);
}
}
return !last ? this.resolveBreadcrumbs(route.firstChild) : observableOf([]);
}
/**
* Unsubscribe from subscription
*/
ngOnDestroy(): void {
if (hasValue(this.subscription)) {
this.subscription.unsubscribe();
}
}
/**
* Resets the state of the breadcrumbs
*/
reset() {
this.breadcrumbs = [];
this.showBreadcrumbs = true;
}
}

View File

@@ -1,7 +1,6 @@
import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs'; import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs';
import { distinctUntilChanged, filter, map, mergeMap, tap } from 'rxjs/operators'; import { distinctUntilChanged, filter, map, mergeMap, tap } from 'rxjs/operators';
import { Inject, Injectable } from '@angular/core'; import { Inject, Injectable } from '@angular/core';
import { EPersonDataService } from '../eperson/eperson-data.service';
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 { GLOBAL_CONFIG } from '../../../config'; import { GLOBAL_CONFIG } from '../../../config';

View File

@@ -35,6 +35,9 @@ export const AuthActionTypes = {
REGISTRATION_ERROR: type('dspace/auth/REGISTRATION_ERROR'), REGISTRATION_ERROR: type('dspace/auth/REGISTRATION_ERROR'),
REGISTRATION_SUCCESS: type('dspace/auth/REGISTRATION_SUCCESS'), REGISTRATION_SUCCESS: type('dspace/auth/REGISTRATION_SUCCESS'),
SET_REDIRECT_URL: type('dspace/auth/SET_REDIRECT_URL'), SET_REDIRECT_URL: type('dspace/auth/SET_REDIRECT_URL'),
RETRIEVE_AUTHENTICATED_EPERSON: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON'),
RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS'),
RETRIEVE_AUTHENTICATED_EPERSON_ERROR: type('dspace/auth/RETRIEVE_AUTHENTICATED_EPERSON_ERROR'),
}; };
/* tslint:disable:max-classes-per-file */ /* tslint:disable:max-classes-per-file */
@@ -52,7 +55,7 @@ export class AuthenticateAction implements Action {
}; };
constructor(email: string, password: string) { constructor(email: string, password: string) {
this.payload = {email, password}; this.payload = { email, password };
} }
} }
@@ -80,11 +83,11 @@ export class AuthenticatedSuccessAction implements Action {
payload: { payload: {
authenticated: boolean; authenticated: boolean;
authToken: AuthTokenInfo; authToken: AuthTokenInfo;
user: EPerson userHref: string
}; };
constructor(authenticated: boolean, authToken: AuthTokenInfo, user: EPerson) { constructor(authenticated: boolean, authToken: AuthTokenInfo, userHref: string) {
this.payload = {authenticated, authToken, user}; this.payload = { authenticated, authToken, userHref };
} }
} }
@@ -378,6 +381,47 @@ export class SetRedirectUrlAction implements Action {
} }
} }
/**
* Retrieve the authenticated eperson.
* @class RetrieveAuthenticatedEpersonAction
* @implements {Action}
*/
export class RetrieveAuthenticatedEpersonAction implements Action {
public type: string = AuthActionTypes.RETRIEVE_AUTHENTICATED_EPERSON;
payload: string;
constructor(user: string) {
this.payload = user ;
}
}
/**
* Set the authenticated eperson in the state.
* @class RetrieveAuthenticatedEpersonSuccessAction
* @implements {Action}
*/
export class RetrieveAuthenticatedEpersonSuccessAction implements Action {
public type: string = AuthActionTypes.RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS;
payload: EPerson;
constructor(user: EPerson) {
this.payload = user ;
}
}
/**
* Set the authenticated eperson in the state.
* @class RetrieveAuthenticatedEpersonSuccessAction
* @implements {Action}
*/
export class RetrieveAuthenticatedEpersonErrorAction implements Action {
public type: string = AuthActionTypes.RETRIEVE_AUTHENTICATED_EPERSON_ERROR;
payload: Error;
constructor(payload: Error) {
this.payload = payload ;
}
}
/* tslint:enable:max-classes-per-file */ /* tslint:enable:max-classes-per-file */
/** /**
@@ -406,4 +450,9 @@ export type AuthActions
| RetrieveAuthMethodsAction | RetrieveAuthMethodsAction
| RetrieveAuthMethodsSuccessAction | RetrieveAuthMethodsSuccessAction
| RetrieveAuthMethodsErrorAction | RetrieveAuthMethodsErrorAction
| RetrieveTokenAction; | RetrieveTokenAction
| ResetAuthenticationMessagesAction
| RetrieveAuthenticatedEpersonAction
| RetrieveAuthenticatedEpersonErrorAction
| RetrieveAuthenticatedEpersonSuccessAction
| SetRedirectUrlAction;

View File

@@ -19,6 +19,9 @@ import {
LogOutSuccessAction, LogOutSuccessAction,
RefreshTokenErrorAction, RefreshTokenErrorAction,
RefreshTokenSuccessAction, RefreshTokenSuccessAction,
RetrieveAuthenticatedEpersonAction,
RetrieveAuthenticatedEpersonErrorAction,
RetrieveAuthenticatedEpersonSuccessAction,
RetrieveAuthMethodsAction, RetrieveAuthMethodsAction,
RetrieveAuthMethodsErrorAction, RetrieveAuthMethodsErrorAction,
RetrieveAuthMethodsSuccessAction, RetrieveAuthMethodsSuccessAction,
@@ -27,7 +30,6 @@ import {
import { authMethodsMock, AuthServiceStub } from '../../shared/testing/auth-service-stub'; import { authMethodsMock, AuthServiceStub } from '../../shared/testing/auth-service-stub';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { AuthState } from './auth.reducer'; import { AuthState } from './auth.reducer';
import { EPersonMock } from '../../shared/testing/eperson-mock'; import { EPersonMock } from '../../shared/testing/eperson-mock';
import { AuthStatus } from './models/auth-status.model'; import { AuthStatus } from './models/auth-status.model';
@@ -47,13 +49,14 @@ describe('AuthEffects', () => {
authServiceStub = new AuthServiceStub(); authServiceStub = new AuthServiceStub();
token = authServiceStub.getToken(); token = authServiceStub.getToken();
} }
beforeEach(() => { beforeEach(() => {
init(); init();
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
AuthEffects, AuthEffects,
{provide: AuthService, useValue: authServiceStub}, { provide: AuthService, useValue: authServiceStub },
{provide: Store, useValue: store}, { provide: Store, useValue: store },
provideMockActions(() => actions), provideMockActions(() => actions),
// other providers // other providers
], ],
@@ -68,11 +71,11 @@ describe('AuthEffects', () => {
actions = hot('--a-', { actions = hot('--a-', {
a: { a: {
type: AuthActionTypes.AUTHENTICATE, type: AuthActionTypes.AUTHENTICATE,
payload: {email: 'user', password: 'password'} payload: { email: 'user', password: 'password' }
} }
}); });
const expected = cold('--b-', {b: new AuthenticationSuccessAction(token)}); const expected = cold('--b-', { b: new AuthenticationSuccessAction(token) });
expect(authEffects.authenticate$).toBeObservable(expected); expect(authEffects.authenticate$).toBeObservable(expected);
}); });
@@ -85,11 +88,11 @@ describe('AuthEffects', () => {
actions = hot('--a-', { actions = hot('--a-', {
a: { a: {
type: AuthActionTypes.AUTHENTICATE, type: AuthActionTypes.AUTHENTICATE,
payload: {email: 'user', password: 'wrongpassword'} payload: { email: 'user', password: 'wrongpassword' }
} }
}); });
const expected = cold('--b-', {b: new AuthenticationErrorAction(new Error('Message Error test'))}); const expected = cold('--b-', { b: new AuthenticationErrorAction(new Error('Message Error test')) });
expect(authEffects.authenticate$).toBeObservable(expected); expect(authEffects.authenticate$).toBeObservable(expected);
}); });
@@ -99,9 +102,9 @@ describe('AuthEffects', () => {
describe('authenticateSuccess$', () => { describe('authenticateSuccess$', () => {
it('should return a AUTHENTICATED action in response to a AUTHENTICATE_SUCCESS action', () => { it('should return a AUTHENTICATED action in response to a AUTHENTICATE_SUCCESS action', () => {
actions = hot('--a-', {a: {type: AuthActionTypes.AUTHENTICATE_SUCCESS, payload: token}}); actions = hot('--a-', { a: { type: AuthActionTypes.AUTHENTICATE_SUCCESS, payload: token } });
const expected = cold('--b-', {b: new AuthenticatedAction(token)}); const expected = cold('--b-', { b: new AuthenticatedAction(token) });
expect(authEffects.authenticateSuccess$).toBeObservable(expected); expect(authEffects.authenticateSuccess$).toBeObservable(expected);
}); });
@@ -111,9 +114,9 @@ describe('AuthEffects', () => {
describe('when token is valid', () => { describe('when token is valid', () => {
it('should return a AUTHENTICATED_SUCCESS action in response to a AUTHENTICATED action', () => { it('should return a AUTHENTICATED_SUCCESS action in response to a AUTHENTICATED action', () => {
actions = hot('--a-', {a: {type: AuthActionTypes.AUTHENTICATED, payload: token}}); actions = hot('--a-', { a: { type: AuthActionTypes.AUTHENTICATED, payload: token } });
const expected = cold('--b-', {b: new AuthenticatedSuccessAction(true, token, EPersonMock)}); const expected = cold('--b-', { b: new AuthenticatedSuccessAction(true, token, EPersonMock._links.self.href) });
expect(authEffects.authenticated$).toBeObservable(expected); expect(authEffects.authenticated$).toBeObservable(expected);
}); });
@@ -123,23 +126,42 @@ describe('AuthEffects', () => {
it('should return a AUTHENTICATED_ERROR action in response to a AUTHENTICATED action', () => { it('should return a AUTHENTICATED_ERROR action in response to a AUTHENTICATED action', () => {
spyOn((authEffects as any).authService, 'authenticatedUser').and.returnValue(observableThrow(new Error('Message Error test'))); spyOn((authEffects as any).authService, 'authenticatedUser').and.returnValue(observableThrow(new Error('Message Error test')));
actions = hot('--a-', {a: {type: AuthActionTypes.AUTHENTICATED, payload: token}}); actions = hot('--a-', { a: { type: AuthActionTypes.AUTHENTICATED, payload: token } });
const expected = cold('--b-', {b: new AuthenticatedErrorAction(new Error('Message Error test'))}); const expected = cold('--b-', { b: new AuthenticatedErrorAction(new Error('Message Error test')) });
expect(authEffects.authenticated$).toBeObservable(expected); expect(authEffects.authenticated$).toBeObservable(expected);
}); });
}); });
}); });
describe('authenticatedSuccess$', () => {
it('should return a RETRIEVE_AUTHENTICATED_EPERSON action in response to a AUTHENTICATED_SUCCESS action', () => {
actions = hot('--a-', {
a: {
type: AuthActionTypes.AUTHENTICATED_SUCCESS, payload: {
authenticated: true,
authToken: token,
userHref: EPersonMock._links.self.href
}
}
});
const expected = cold('--b-', { b: new RetrieveAuthenticatedEpersonAction(EPersonMock._links.self.href) });
expect(authEffects.authenticatedSuccess$).toBeObservable(expected);
});
});
describe('checkToken$', () => { describe('checkToken$', () => {
describe('when check token succeeded', () => { describe('when check token succeeded', () => {
it('should return a AUTHENTICATED action in response to a CHECK_AUTHENTICATION_TOKEN action', () => { it('should return a AUTHENTICATED action in response to a CHECK_AUTHENTICATION_TOKEN action', () => {
actions = hot('--a-', {a: {type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN}}); actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN } });
const expected = cold('--b-', {b: new AuthenticatedAction(token)}); const expected = cold('--b-', { b: new AuthenticatedAction(token) });
expect(authEffects.checkToken$).toBeObservable(expected); expect(authEffects.checkToken$).toBeObservable(expected);
}); });
@@ -149,9 +171,9 @@ describe('AuthEffects', () => {
it('should return a CHECK_AUTHENTICATION_TOKEN_ERROR action in response to a CHECK_AUTHENTICATION_TOKEN action', () => { it('should return a CHECK_AUTHENTICATION_TOKEN_ERROR action in response to a CHECK_AUTHENTICATION_TOKEN action', () => {
spyOn((authEffects as any).authService, 'hasValidAuthenticationToken').and.returnValue(observableThrow('')); spyOn((authEffects as any).authService, 'hasValidAuthenticationToken').and.returnValue(observableThrow(''));
actions = hot('--a-', {a: {type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN, payload: token}}); actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN, payload: token } });
const expected = cold('--b-', {b: new CheckAuthenticationTokenCookieAction()}); const expected = cold('--b-', { b: new CheckAuthenticationTokenCookieAction() });
expect(authEffects.checkToken$).toBeObservable(expected); expect(authEffects.checkToken$).toBeObservable(expected);
}); });
@@ -164,12 +186,13 @@ describe('AuthEffects', () => {
it('should return a RETRIEVE_TOKEN action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is true', () => { it('should return a RETRIEVE_TOKEN action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is true', () => {
spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue( spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue(
observableOf( observableOf(
{ authenticated: true {
authenticated: true
}) })
); );
actions = hot('--a-', {a: {type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE}}); actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } });
const expected = cold('--b-', {b: new RetrieveTokenAction()}); const expected = cold('--b-', { b: new RetrieveTokenAction() });
expect(authEffects.checkTokenCookie$).toBeObservable(expected); expect(authEffects.checkTokenCookie$).toBeObservable(expected);
}); });
@@ -179,9 +202,9 @@ describe('AuthEffects', () => {
observableOf( observableOf(
{ authenticated: false }) { authenticated: false })
); );
actions = hot('--a-', {a: {type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE}}); actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } });
const expected = cold('--b-', {b: new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus)}); const expected = cold('--b-', { b: new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus) });
expect(authEffects.checkTokenCookie$).toBeObservable(expected); expect(authEffects.checkTokenCookie$).toBeObservable(expected);
}); });
@@ -191,23 +214,53 @@ describe('AuthEffects', () => {
it('should return a AUTHENTICATED_ERROR action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action', () => { it('should return a AUTHENTICATED_ERROR action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action', () => {
spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue(observableThrow(new Error('Message Error test'))); spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue(observableThrow(new Error('Message Error test')));
actions = hot('--a-', {a: {type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE, payload: token}}); actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE, payload: token } });
const expected = cold('--b-', {b: new AuthenticatedErrorAction(new Error('Message Error test'))}); const expected = cold('--b-', { b: new AuthenticatedErrorAction(new Error('Message Error test')) });
expect(authEffects.checkTokenCookie$).toBeObservable(expected); expect(authEffects.checkTokenCookie$).toBeObservable(expected);
}); });
}) })
}); });
describe('retrieveAuthenticatedEperson$', () => {
describe('when request is successful', () => {
it('should return a RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS action in response to a RETRIEVE_AUTHENTICATED_EPERSON action', () => {
actions = hot('--a-', {
a: {
type: AuthActionTypes.RETRIEVE_AUTHENTICATED_EPERSON,
payload: EPersonMock._links.self.href
}
});
const expected = cold('--b-', { b: new RetrieveAuthenticatedEpersonSuccessAction(EPersonMock) });
expect(authEffects.retrieveAuthenticatedEperson$).toBeObservable(expected);
});
});
describe('when request is not successful', () => {
it('should return a RETRIEVE_AUTHENTICATED_EPERSON_ERROR action in response to a RETRIEVE_AUTHENTICATED_EPERSON action', () => {
spyOn((authEffects as any).authService, 'retrieveAuthenticatedUserByHref').and.returnValue(observableThrow(new Error('Message Error test')));
actions = hot('--a-', { a: { type: AuthActionTypes.RETRIEVE_AUTHENTICATED_EPERSON, payload: token } });
const expected = cold('--b-', { b: new RetrieveAuthenticatedEpersonErrorAction(new Error('Message Error test')) });
expect(authEffects.retrieveAuthenticatedEperson$).toBeObservable(expected);
});
});
});
describe('refreshToken$', () => { describe('refreshToken$', () => {
describe('when refresh token succeeded', () => { describe('when refresh token succeeded', () => {
it('should return a REFRESH_TOKEN_SUCCESS action in response to a REFRESH_TOKEN action', () => { it('should return a REFRESH_TOKEN_SUCCESS action in response to a REFRESH_TOKEN action', () => {
actions = hot('--a-', {a: {type: AuthActionTypes.REFRESH_TOKEN}}); actions = hot('--a-', { a: { type: AuthActionTypes.REFRESH_TOKEN } });
const expected = cold('--b-', {b: new RefreshTokenSuccessAction(token)}); const expected = cold('--b-', { b: new RefreshTokenSuccessAction(token) });
expect(authEffects.refreshToken$).toBeObservable(expected); expect(authEffects.refreshToken$).toBeObservable(expected);
}); });
@@ -217,9 +270,9 @@ describe('AuthEffects', () => {
it('should return a REFRESH_TOKEN_ERROR action in response to a REFRESH_TOKEN action', () => { it('should return a REFRESH_TOKEN_ERROR action in response to a REFRESH_TOKEN action', () => {
spyOn((authEffects as any).authService, 'refreshAuthenticationToken').and.returnValue(observableThrow('')); spyOn((authEffects as any).authService, 'refreshAuthenticationToken').and.returnValue(observableThrow(''));
actions = hot('--a-', {a: {type: AuthActionTypes.REFRESH_TOKEN, payload: token}}); actions = hot('--a-', { a: { type: AuthActionTypes.REFRESH_TOKEN, payload: token } });
const expected = cold('--b-', {b: new RefreshTokenErrorAction()}); const expected = cold('--b-', { b: new RefreshTokenErrorAction() });
expect(authEffects.refreshToken$).toBeObservable(expected); expect(authEffects.refreshToken$).toBeObservable(expected);
}); });
@@ -235,7 +288,7 @@ describe('AuthEffects', () => {
} }
}); });
const expected = cold('--b-', {b: new AuthenticationSuccessAction(token)}); const expected = cold('--b-', { b: new AuthenticationSuccessAction(token) });
expect(authEffects.retrieveToken$).toBeObservable(expected); expect(authEffects.retrieveToken$).toBeObservable(expected);
}); });
@@ -251,7 +304,7 @@ describe('AuthEffects', () => {
} }
}); });
const expected = cold('--b-', {b: new AuthenticationErrorAction(new Error('Message Error test'))}); const expected = cold('--b-', { b: new AuthenticationErrorAction(new Error('Message Error test')) });
expect(authEffects.retrieveToken$).toBeObservable(expected); expect(authEffects.retrieveToken$).toBeObservable(expected);
}); });
@@ -263,9 +316,9 @@ describe('AuthEffects', () => {
describe('when refresh token succeeded', () => { describe('when refresh token succeeded', () => {
it('should return a LOG_OUT_SUCCESS action in response to a LOG_OUT action', () => { it('should return a LOG_OUT_SUCCESS action in response to a LOG_OUT action', () => {
actions = hot('--a-', {a: {type: AuthActionTypes.LOG_OUT}}); actions = hot('--a-', { a: { type: AuthActionTypes.LOG_OUT } });
const expected = cold('--b-', {b: new LogOutSuccessAction()}); const expected = cold('--b-', { b: new LogOutSuccessAction() });
expect(authEffects.logOut$).toBeObservable(expected); expect(authEffects.logOut$).toBeObservable(expected);
}); });
@@ -275,9 +328,9 @@ describe('AuthEffects', () => {
it('should return a REFRESH_TOKEN_ERROR action in response to a LOG_OUT action', () => { it('should return a REFRESH_TOKEN_ERROR action in response to a LOG_OUT action', () => {
spyOn((authEffects as any).authService, 'logout').and.returnValue(observableThrow(new Error('Message Error test'))); spyOn((authEffects as any).authService, 'logout').and.returnValue(observableThrow(new Error('Message Error test')));
actions = hot('--a-', {a: {type: AuthActionTypes.LOG_OUT, payload: token}}); actions = hot('--a-', { a: { type: AuthActionTypes.LOG_OUT, payload: token } });
const expected = cold('--b-', {b: new LogOutErrorAction(new Error('Message Error test'))}); const expected = cold('--b-', { b: new LogOutErrorAction(new Error('Message Error test')) });
expect(authEffects.logOut$).toBeObservable(expected); expect(authEffects.logOut$).toBeObservable(expected);
}); });
@@ -288,9 +341,9 @@ describe('AuthEffects', () => {
describe('when retrieve authentication methods succeeded', () => { describe('when retrieve authentication methods succeeded', () => {
it('should return a RETRIEVE_AUTH_METHODS_SUCCESS action in response to a RETRIEVE_AUTH_METHODS action', () => { it('should return a RETRIEVE_AUTH_METHODS_SUCCESS action in response to a RETRIEVE_AUTH_METHODS action', () => {
actions = hot('--a-', {a: {type: AuthActionTypes.RETRIEVE_AUTH_METHODS}}); actions = hot('--a-', { a: { type: AuthActionTypes.RETRIEVE_AUTH_METHODS } });
const expected = cold('--b-', {b: new RetrieveAuthMethodsSuccessAction(authMethodsMock)}); const expected = cold('--b-', { b: new RetrieveAuthMethodsSuccessAction(authMethodsMock) });
expect(authEffects.retrieveMethods$).toBeObservable(expected); expect(authEffects.retrieveMethods$).toBeObservable(expected);
}); });
@@ -300,9 +353,9 @@ describe('AuthEffects', () => {
it('should return a RETRIEVE_AUTH_METHODS_ERROR action in response to a RETRIEVE_AUTH_METHODS action', () => { it('should return a RETRIEVE_AUTH_METHODS_ERROR action in response to a RETRIEVE_AUTH_METHODS action', () => {
spyOn((authEffects as any).authService, 'retrieveAuthMethodsFromAuthStatus').and.returnValue(observableThrow('')); spyOn((authEffects as any).authService, 'retrieveAuthMethodsFromAuthStatus').and.returnValue(observableThrow(''));
actions = hot('--a-', {a: {type: AuthActionTypes.RETRIEVE_AUTH_METHODS}}); actions = hot('--a-', { a: { type: AuthActionTypes.RETRIEVE_AUTH_METHODS } });
const expected = cold('--b-', {b: new RetrieveAuthMethodsErrorAction()}); const expected = cold('--b-', { b: new RetrieveAuthMethodsErrorAction() });
expect(authEffects.retrieveMethods$).toBeObservable(expected); expect(authEffects.retrieveMethods$).toBeObservable(expected);
}); });

View File

@@ -2,11 +2,21 @@ import { Observable, of as observableOf } from 'rxjs';
import { catchError, debounceTime, filter, map, switchMap, take, tap } from 'rxjs/operators'; import { catchError, debounceTime, filter, map, switchMap, take, tap } from 'rxjs/operators';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
// import @ngrx // import @ngrx
import { Actions, Effect, ofType } from '@ngrx/effects'; import { Actions, Effect, ofType } from '@ngrx/effects';
import { Action, select, Store } from '@ngrx/store'; import { Action, select, Store } from '@ngrx/store';
// import services // import services
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { EPerson } from '../eperson/models/eperson.model';
import { AuthStatus } from './models/auth-status.model';
import { AuthTokenInfo } from './models/auth-token-info.model';
import { AppState } from '../../app.reducer';
import { isAuthenticated } from './selectors';
import { StoreActionTypes } from '../../store.actions';
import { AuthMethod } from './models/auth.method';
// import actions // import actions
import { import {
AuthActionTypes, AuthActionTypes,
@@ -25,17 +35,14 @@ import {
RegistrationAction, RegistrationAction,
RegistrationErrorAction, RegistrationErrorAction,
RegistrationSuccessAction, RegistrationSuccessAction,
RetrieveAuthenticatedEpersonAction,
RetrieveAuthenticatedEpersonErrorAction,
RetrieveAuthenticatedEpersonSuccessAction,
RetrieveAuthMethodsAction, RetrieveAuthMethodsAction,
RetrieveAuthMethodsErrorAction, RetrieveAuthMethodsErrorAction,
RetrieveAuthMethodsSuccessAction, RetrieveTokenAction RetrieveAuthMethodsSuccessAction,
RetrieveTokenAction
} from './auth.actions'; } from './auth.actions';
import { EPerson } from '../eperson/models/eperson.model';
import { AuthStatus } from './models/auth-status.model';
import { AuthTokenInfo } from './models/auth-token-info.model';
import { AppState } from '../../app.reducer';
import { isAuthenticated } from './selectors';
import { StoreActionTypes } from '../../store.actions';
import { AuthMethod } from './models/auth.method';
@Injectable() @Injectable()
export class AuthEffects { export class AuthEffects {
@@ -46,39 +53,55 @@ export class AuthEffects {
*/ */
@Effect() @Effect()
public authenticate$: Observable<Action> = this.actions$.pipe( public authenticate$: Observable<Action> = this.actions$.pipe(
ofType(AuthActionTypes.AUTHENTICATE), ofType(AuthActionTypes.AUTHENTICATE),
switchMap((action: AuthenticateAction) => { switchMap((action: AuthenticateAction) => {
return this.authService.authenticate(action.payload.email, action.payload.password).pipe( return this.authService.authenticate(action.payload.email, action.payload.password).pipe(
take(1), take(1),
map((response: AuthStatus) => new AuthenticationSuccessAction(response.token)), map((response: AuthStatus) => new AuthenticationSuccessAction(response.token)),
catchError((error) => observableOf(new AuthenticationErrorAction(error))) catchError((error) => observableOf(new AuthenticationErrorAction(error)))
); );
}) })
); );
@Effect() @Effect()
public authenticateSuccess$: Observable<Action> = this.actions$.pipe( public authenticateSuccess$: Observable<Action> = this.actions$.pipe(
ofType(AuthActionTypes.AUTHENTICATE_SUCCESS), ofType(AuthActionTypes.AUTHENTICATE_SUCCESS),
tap((action: AuthenticationSuccessAction) => this.authService.storeToken(action.payload)), tap((action: AuthenticationSuccessAction) => this.authService.storeToken(action.payload)),
map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload)) map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload))
); );
@Effect() @Effect()
public authenticated$: Observable<Action> = this.actions$.pipe( public authenticated$: Observable<Action> = this.actions$.pipe(
ofType(AuthActionTypes.AUTHENTICATED), ofType(AuthActionTypes.AUTHENTICATED),
switchMap((action: AuthenticatedAction) => { switchMap((action: AuthenticatedAction) => {
return this.authService.authenticatedUser(action.payload).pipe( return this.authService.authenticatedUser(action.payload).pipe(
map((user: EPerson) => new AuthenticatedSuccessAction((user !== null), action.payload, user)), map((userHref: string) => new AuthenticatedSuccessAction((userHref !== null), action.payload, userHref)),
catchError((error) => observableOf(new AuthenticatedErrorAction(error))),); catchError((error) => observableOf(new AuthenticatedErrorAction(error))),);
}) })
); );
@Effect()
public authenticatedSuccess$: Observable<Action> = this.actions$.pipe(
ofType(AuthActionTypes.AUTHENTICATED_SUCCESS),
map((action: AuthenticatedSuccessAction) => new RetrieveAuthenticatedEpersonAction(action.payload.userHref))
);
// It means "reacts to this action but don't send another" // It means "reacts to this action but don't send another"
@Effect({ dispatch: false }) @Effect({ dispatch: false })
public authenticatedError$: Observable<Action> = this.actions$.pipe( public authenticatedError$: Observable<Action> = this.actions$.pipe(
ofType(AuthActionTypes.AUTHENTICATED_ERROR), ofType(AuthActionTypes.AUTHENTICATED_ERROR),
tap((action: LogOutSuccessAction) => this.authService.removeToken()) tap((action: LogOutSuccessAction) => this.authService.removeToken())
); );
@Effect()
public retrieveAuthenticatedEperson$: Observable<Action> = this.actions$.pipe(
ofType(AuthActionTypes.RETRIEVE_AUTHENTICATED_EPERSON),
switchMap((action: RetrieveAuthenticatedEpersonAction) => {
return this.authService.retrieveAuthenticatedUserByHref(action.payload).pipe(
map((user: EPerson) => new RetrieveAuthenticatedEpersonSuccessAction(user)),
catchError((error) => observableOf(new RetrieveAuthenticatedEpersonErrorAction(error))));
})
);
@Effect() @Effect()
public checkToken$: Observable<Action> = this.actions$.pipe(ofType(AuthActionTypes.CHECK_AUTHENTICATION_TOKEN), public checkToken$: Observable<Action> = this.actions$.pipe(ofType(AuthActionTypes.CHECK_AUTHENTICATION_TOKEN),
@@ -109,15 +132,15 @@ export class AuthEffects {
@Effect() @Effect()
public createUser$: Observable<Action> = this.actions$.pipe( public createUser$: Observable<Action> = this.actions$.pipe(
ofType(AuthActionTypes.REGISTRATION), ofType(AuthActionTypes.REGISTRATION),
debounceTime(500), // to remove when functionality is implemented debounceTime(500), // to remove when functionality is implemented
switchMap((action: RegistrationAction) => { switchMap((action: RegistrationAction) => {
return this.authService.create(action.payload).pipe( return this.authService.create(action.payload).pipe(
map((user: EPerson) => new RegistrationSuccessAction(user)), map((user: EPerson) => new RegistrationSuccessAction(user)),
catchError((error) => observableOf(new RegistrationErrorAction(error))) catchError((error) => observableOf(new RegistrationErrorAction(error)))
); );
}) })
); );
@Effect() @Effect()
public retrieveToken$: Observable<Action> = this.actions$.pipe( public retrieveToken$: Observable<Action> = this.actions$.pipe(
@@ -133,20 +156,20 @@ export class AuthEffects {
@Effect() @Effect()
public refreshToken$: Observable<Action> = this.actions$.pipe(ofType(AuthActionTypes.REFRESH_TOKEN), public refreshToken$: Observable<Action> = this.actions$.pipe(ofType(AuthActionTypes.REFRESH_TOKEN),
switchMap((action: RefreshTokenAction) => { switchMap((action: RefreshTokenAction) => {
return this.authService.refreshAuthenticationToken(action.payload).pipe( return this.authService.refreshAuthenticationToken(action.payload).pipe(
map((token: AuthTokenInfo) => new RefreshTokenSuccessAction(token)), map((token: AuthTokenInfo) => new RefreshTokenSuccessAction(token)),
catchError((error) => observableOf(new RefreshTokenErrorAction())) catchError((error) => observableOf(new RefreshTokenErrorAction()))
); );
}) })
); );
// It means "reacts to this action but don't send another" // It means "reacts to this action but don't send another"
@Effect({ dispatch: false }) @Effect({ dispatch: false })
public refreshTokenSuccess$: Observable<Action> = this.actions$.pipe( public refreshTokenSuccess$: Observable<Action> = this.actions$.pipe(
ofType(AuthActionTypes.REFRESH_TOKEN_SUCCESS), ofType(AuthActionTypes.REFRESH_TOKEN_SUCCESS),
tap((action: RefreshTokenSuccessAction) => this.authService.replaceToken(action.payload)) tap((action: RefreshTokenSuccessAction) => this.authService.replaceToken(action.payload))
); );
/** /**
* When the store is rehydrated in the browser, * When the store is rehydrated in the browser,

View File

@@ -18,6 +18,8 @@ import {
RefreshTokenErrorAction, RefreshTokenErrorAction,
RefreshTokenSuccessAction, RefreshTokenSuccessAction,
ResetAuthenticationMessagesAction, ResetAuthenticationMessagesAction,
RetrieveAuthenticatedEpersonErrorAction,
RetrieveAuthenticatedEpersonSuccessAction,
RetrieveAuthMethodsAction, RetrieveAuthMethodsAction,
RetrieveAuthMethodsErrorAction, RetrieveAuthMethodsErrorAction,
RetrieveAuthMethodsSuccessAction, RetrieveAuthMethodsSuccessAction,
@@ -113,16 +115,15 @@ describe('authReducer', () => {
loading: true, loading: true,
info: undefined info: undefined
}; };
const action = new AuthenticatedSuccessAction(true, mockTokenInfo, EPersonMock); const action = new AuthenticatedSuccessAction(true, mockTokenInfo, EPersonMock._links.self.href);
const newState = authReducer(initialState, action); const newState = authReducer(initialState, action);
state = { state = {
authenticated: true, authenticated: true,
authToken: mockTokenInfo, authToken: mockTokenInfo,
loaded: true, loaded: false,
error: undefined, error: undefined,
loading: false, loading: true,
info: undefined, info: undefined
user: EPersonMock
}; };
expect(newState).toEqual(state); expect(newState).toEqual(state);
}); });
@@ -248,6 +249,50 @@ describe('authReducer', () => {
expect(newState).toEqual(state); expect(newState).toEqual(state);
}); });
it('should properly set the state, in response to a RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS action', () => {
initialState = {
authenticated: true,
authToken: mockTokenInfo,
loaded: false,
error: undefined,
loading: true,
info: undefined
};
const action = new RetrieveAuthenticatedEpersonSuccessAction(EPersonMock);
const newState = authReducer(initialState, action);
state = {
authenticated: true,
authToken: mockTokenInfo,
loaded: true,
error: undefined,
loading: false,
info: undefined,
user: EPersonMock
};
expect(newState).toEqual(state);
});
it('should properly set the state, in response to a RETRIEVE_AUTHENTICATED_EPERSON_ERROR action', () => {
initialState = {
authenticated: false,
loaded: false,
error: undefined,
loading: true,
info: undefined
};
const action = new RetrieveAuthenticatedEpersonErrorAction(mockError);
const newState = authReducer(initialState, action);
state = {
authenticated: false,
authToken: undefined,
error: 'Test error message',
loaded: true,
loading: false,
info: undefined
};
expect(newState).toEqual(state);
});
it('should properly set the state, in response to a REFRESH_TOKEN action', () => { it('should properly set the state, in response to a REFRESH_TOKEN action', () => {
initialState = { initialState = {
authenticated: true, authenticated: true,

View File

@@ -9,6 +9,7 @@ import {
RedirectWhenAuthenticationIsRequiredAction, RedirectWhenAuthenticationIsRequiredAction,
RedirectWhenTokenExpiredAction, RedirectWhenTokenExpiredAction,
RefreshTokenSuccessAction, RefreshTokenSuccessAction,
RetrieveAuthenticatedEpersonSuccessAction,
RetrieveAuthMethodsSuccessAction, RetrieveAuthMethodsSuccessAction,
SetRedirectUrlAction SetRedirectUrlAction
} from './auth.actions'; } from './auth.actions';
@@ -90,6 +91,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
}); });
case AuthActionTypes.AUTHENTICATED_ERROR: case AuthActionTypes.AUTHENTICATED_ERROR:
case AuthActionTypes.RETRIEVE_AUTHENTICATED_EPERSON_ERROR:
return Object.assign({}, state, { return Object.assign({}, state, {
authenticated: false, authenticated: false,
authToken: undefined, authToken: undefined,
@@ -101,12 +103,16 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
case AuthActionTypes.AUTHENTICATED_SUCCESS: case AuthActionTypes.AUTHENTICATED_SUCCESS:
return Object.assign({}, state, { return Object.assign({}, state, {
authenticated: true, authenticated: true,
authToken: (action as AuthenticatedSuccessAction).payload.authToken, authToken: (action as AuthenticatedSuccessAction).payload.authToken
});
case AuthActionTypes.RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS:
return Object.assign({}, state, {
loaded: true, loaded: true,
error: undefined, error: undefined,
loading: false, loading: false,
info: undefined, info: undefined,
user: (action as AuthenticatedSuccessAction).payload.user user: (action as RetrieveAuthenticatedEpersonSuccessAction).payload
}); });
case AuthActionTypes.AUTHENTICATE_ERROR: case AuthActionTypes.AUTHENTICATE_ERROR:

View File

@@ -5,14 +5,12 @@ import { ActivatedRoute, Router } from '@angular/router';
import { Store, StoreModule } from '@ngrx/store'; import { Store, StoreModule } from '@ngrx/store';
import { REQUEST } from '@nguniversal/express-engine/tokens'; import { REQUEST } from '@nguniversal/express-engine/tokens';
import { of as observableOf } from 'rxjs'; import { of as observableOf } from 'rxjs';
import { LinkService } from '../cache/builders/link.service';
import { authReducer, AuthState } from './auth.reducer'; import { authReducer, AuthState } from './auth.reducer';
import { NativeWindowRef, NativeWindowService } from '../services/window.service'; import { NativeWindowRef, NativeWindowService } from '../services/window.service';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { RouterStub } from '../../shared/testing/router-stub'; import { RouterStub } from '../../shared/testing/router-stub';
import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; import { ActivatedRouteStub } from '../../shared/testing/active-router-stub';
import { CookieService } from '../services/cookie.service'; import { CookieService } from '../services/cookie.service';
import { AuthRequestServiceStub } from '../../shared/testing/auth-request-service-stub'; import { AuthRequestServiceStub } from '../../shared/testing/auth-request-service-stub';
import { AuthRequestService } from './auth-request.service'; import { AuthRequestService } from './auth-request.service';
@@ -25,11 +23,21 @@ import { ClientCookieService } from '../services/client-cookie.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { routeServiceStub } from '../../shared/testing/route-service-stub'; import { routeServiceStub } from '../../shared/testing/route-service-stub';
import { RouteService } from '../services/route.service'; import { RouteService } from '../services/route.service';
import { Observable } from 'rxjs/internal/Observable';
import { RemoteData } from '../data/remote-data';
import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils';
import { EPersonDataService } from '../eperson/eperson-data.service';
import { authMethodsMock } from '../../shared/testing/auth-service-stub'; import { authMethodsMock } from '../../shared/testing/auth-service-stub';
import { AuthMethod } from './models/auth.method'; import { AuthMethod } from './models/auth.method';
describe('AuthService test', () => { describe('AuthService test', () => {
const mockEpersonDataService: any = {
findByHref(href: string): Observable<RemoteData<EPerson>> {
return createSuccessfulRemoteDataObject$(EPersonMock);
}
};
let mockStore: Store<AuthState>; let mockStore: Store<AuthState>;
let authService: AuthService; let authService: AuthService;
let routeServiceMock: RouteService; let routeServiceMock: RouteService;
@@ -84,7 +92,7 @@ describe('AuthService test', () => {
{ provide: RouteService, useValue: routeServiceStub }, { provide: RouteService, useValue: routeServiceStub },
{ provide: ActivatedRoute, useValue: routeStub }, { provide: ActivatedRoute, useValue: routeStub },
{ provide: Store, useValue: mockStore }, { provide: Store, useValue: mockStore },
{ provide: LinkService, useValue: linkService }, { provide: EPersonDataService, useValue: mockEpersonDataService },
CookieService, CookieService,
AuthService AuthService
], ],
@@ -102,8 +110,14 @@ describe('AuthService test', () => {
expect(authService.authenticate.bind(null, 'user', 'passwordwrong')).toThrow(); expect(authService.authenticate.bind(null, 'user', 'passwordwrong')).toThrow();
}); });
it('should return the authenticated user object when user token is valid', () => { it('should return the authenticated user href when user token is valid', () => {
authService.authenticatedUser(new AuthTokenInfo('test_token')).subscribe((user: EPerson) => { authService.authenticatedUser(new AuthTokenInfo('test_token')).subscribe((userHref: string) => {
expect(userHref).toBeDefined();
});
});
it('should return the authenticated user', () => {
authService.retrieveAuthenticatedUserByHref(EPersonMock._links.self.href).subscribe((user: EPerson) => {
expect(user).toBeDefined(); expect(user).toBeDefined();
}); });
}); });
@@ -180,7 +194,7 @@ describe('AuthService test', () => {
(state as any).core = Object.create({}); (state as any).core = Object.create({});
(state as any).core.auth = authenticatedState; (state as any).core.auth = authenticatedState;
}); });
authService = new AuthService({}, window, undefined, authReqService, router, routeService, cookieService, store, linkService); authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store);
})); }));
it('should return true when user is logged in', () => { it('should return true when user is logged in', () => {
@@ -242,7 +256,7 @@ describe('AuthService test', () => {
(state as any).core = Object.create({}); (state as any).core = Object.create({});
(state as any).core.auth = authenticatedState; (state as any).core.auth = authenticatedState;
}); });
authService = new AuthService({}, window, undefined, authReqService, router, routeService, cookieService, store, linkService); authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store);
storage = (authService as any).storage; storage = (authService as any).storage;
routeServiceMock = TestBed.get(RouteService); routeServiceMock = TestBed.get(RouteService);
routerStub = TestBed.get(Router); routerStub = TestBed.get(Router);

View File

@@ -4,13 +4,11 @@ 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 { distinctUntilChanged, filter, map, startWith, switchMap, take, withLatestFrom } from 'rxjs/operators'; import { distinctUntilChanged, filter, map, startWith, take, withLatestFrom } from 'rxjs/operators';
import { RouterReducerState } from '@ngrx/router-store'; import { RouterReducerState } from '@ngrx/router-store';
import { select, Store } from '@ngrx/store'; import { select, Store } from '@ngrx/store';
import { CookieAttributes } from 'js-cookie'; import { CookieAttributes } from 'js-cookie';
import { followLink } from '../../shared/utils/follow-link-config.model';
import { LinkService } from '../cache/builders/link.service';
import { EPerson } from '../eperson/models/eperson.model'; import { EPerson } from '../eperson/models/eperson.model';
import { AuthRequestService } from './auth-request.service'; import { AuthRequestService } from './auth-request.service';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
@@ -28,6 +26,8 @@ import {
import { NativeWindowRef, NativeWindowService } from '../services/window.service'; import { NativeWindowRef, NativeWindowService } from '../services/window.service';
import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util'; import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util';
import { RouteService } from '../services/route.service'; import { RouteService } from '../services/route.service';
import { EPersonDataService } from '../eperson/eperson-data.service';
import { getFirstSucceededRemoteDataPayload } from '../shared/operators';
import { AuthMethod } from './models/auth.method'; import { AuthMethod } from './models/auth.method';
export const LOGIN_ROUTE = '/login'; export const LOGIN_ROUTE = '/login';
@@ -48,13 +48,13 @@ export class AuthService {
constructor(@Inject(REQUEST) protected req: any, constructor(@Inject(REQUEST) protected req: any,
@Inject(NativeWindowService) protected _window: NativeWindowRef, @Inject(NativeWindowService) protected _window: NativeWindowRef,
protected authRequestService: AuthRequestService,
@Optional() @Inject(RESPONSE) private response: any, @Optional() @Inject(RESPONSE) private response: any,
protected authRequestService: AuthRequestService,
protected epersonService: EPersonDataService,
protected router: Router, protected router: Router,
protected routeService: RouteService, protected routeService: RouteService,
protected storage: CookieService, protected storage: CookieService,
protected store: Store<AppState>, protected store: Store<AppState>
protected linkService: LinkService
) { ) {
this.store.pipe( this.store.pipe(
select(isAuthenticated), select(isAuthenticated),
@@ -142,10 +142,10 @@ export class AuthService {
} }
/** /**
* Returns the authenticated user * Returns the href link to authenticated user
* @returns {User} * @returns {string}
*/ */
public authenticatedUser(token: AuthTokenInfo): Observable<EPerson> { public authenticatedUser(token: AuthTokenInfo): Observable<string> {
// Determine if the user has an existing auth session on the server // Determine if the user has an existing auth session on the server
const options: HttpOptions = Object.create({}); const options: HttpOptions = Object.create({});
let headers = new HttpHeaders(); let headers = new HttpHeaders();
@@ -153,16 +153,25 @@ export class AuthService {
headers = headers.append('Authorization', `Bearer ${token.accessToken}`); headers = headers.append('Authorization', `Bearer ${token.accessToken}`);
options.headers = headers; options.headers = headers;
return this.authRequestService.getRequest('status', options).pipe( return this.authRequestService.getRequest('status', options).pipe(
map((status) => this.linkService.resolveLinks(status, followLink<AuthStatus>('eperson'))), map((status: AuthStatus) => {
switchMap((status: AuthStatus) => {
if (status.authenticated) { if (status.authenticated) {
return status.eperson.pipe(map((eperson) => eperson.payload)); return status._links.eperson.href;
} else { } else {
throw(new Error('Not authenticated')); throw(new Error('Not authenticated'));
} }
})) }))
} }
/**
* Returns the authenticated user
* @returns {User}
*/
public retrieveAuthenticatedUserByHref(userHref: string): Observable<EPerson> {
return this.epersonService.findByHref(userHref).pipe(
getFirstSucceededRemoteDataPayload()
)
}
/** /**
* Checks if token is present into browser storage and is valid. * Checks if token is present into browser storage and is valid.
*/ */

View File

@@ -2,12 +2,10 @@ import { Injectable } from '@angular/core';
import { HttpHeaders } from '@angular/common/http'; import { HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { filter, map, switchMap, take } from 'rxjs/operators'; import { filter, map, take } from 'rxjs/operators';
import { isNotEmpty } from '../../shared/empty.util'; import { isNotEmpty } from '../../shared/empty.util';
import { followLink } from '../../shared/utils/follow-link-config.model';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { EPerson } from '../eperson/models/eperson.model';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { AuthStatus } from './models/auth-status.model'; import { AuthStatus } from './models/auth-status.model';
import { AuthTokenInfo } from './models/auth-token-info.model'; import { AuthTokenInfo } from './models/auth-token-info.model';
@@ -22,7 +20,7 @@ export class ServerAuthService extends AuthService {
* Returns the authenticated user * Returns the authenticated user
* @returns {User} * @returns {User}
*/ */
public authenticatedUser(token: AuthTokenInfo): Observable<EPerson> { public authenticatedUser(token: AuthTokenInfo): Observable<string> {
// Determine if the user has an existing auth session on the server // Determine if the user has an existing auth session on the server
const options: HttpOptions = Object.create({}); const options: HttpOptions = Object.create({});
let headers = new HttpHeaders(); let headers = new HttpHeaders();
@@ -35,10 +33,9 @@ export class ServerAuthService extends AuthService {
options.headers = headers; options.headers = headers;
return this.authRequestService.getRequest('status', options).pipe( return this.authRequestService.getRequest('status', options).pipe(
map((status) => this.linkService.resolveLinks(status, followLink<AuthStatus>('eperson'))), map((status: AuthStatus) => {
switchMap((status: AuthStatus) => {
if (status.authenticated) { if (status.authenticated) {
return status.eperson.pipe(map((eperson) => eperson.payload)); return status._links.eperson.href;
} else { } else {
throw(new Error('Not authenticated')); throw(new Error('Not authenticated'));
} }

View File

@@ -0,0 +1,15 @@
import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model';
import { Observable } from 'rxjs';
/**
* Service to calculate breadcrumbs for a single part of the route
*/
export interface BreadcrumbsService<T> {
/**
* Method to calculate the breadcrumbs for a part of the route
* @param key The key used to resolve the breadcrumb
* @param url The url to use as a link for this breadcrumb
*/
getBreadcrumbs(key: T, url: string): Observable<Breadcrumb[]>;
}

View File

@@ -0,0 +1,29 @@
import { Injectable } from '@angular/core';
import { DSOBreadcrumbsService } from './dso-breadcrumbs.service';
import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver';
import { Collection } from '../shared/collection.model';
import { CollectionDataService } from '../data/collection-data.service';
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
/**
* The class that resolves the BreadcrumbConfig object for a Collection
*/
@Injectable()
export class CollectionBreadcrumbResolver extends DSOBreadcrumbResolver<Collection> {
constructor(protected breadcrumbService: DSOBreadcrumbsService, protected dataService: CollectionDataService) {
super(breadcrumbService, dataService);
}
/**
* Method that returns the follow links to already resolve
* The self links defined in this list are expected to be requested somewhere in the near future
* Requesting them as embeds will limit the number of requests
*/
get followLinks(): Array<FollowLinkConfig<Collection>> {
return [
followLink('parentCommunity', undefined,
followLink('parentCommunity')
)
];
}
}

View File

@@ -0,0 +1,27 @@
import { Injectable } from '@angular/core';
import { DSOBreadcrumbsService } from './dso-breadcrumbs.service';
import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver';
import { CommunityDataService } from '../data/community-data.service';
import { Community } from '../shared/community.model';
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
/**
* The class that resolves the BreadcrumbConfig object for a Community
*/
@Injectable()
export class CommunityBreadcrumbResolver extends DSOBreadcrumbResolver<Community> {
constructor(protected breadcrumbService: DSOBreadcrumbsService, protected dataService: CommunityDataService) {
super(breadcrumbService, dataService);
}
/**
* Method that returns the follow links to already resolve
* The self links defined in this list are expected to be requested somewhere in the near future
* Requesting them as embeds will limit the number of requests
*/
get followLinks(): Array<FollowLinkConfig<Community>> {
return [
followLink('parentCommunity')
];
}
}

View File

@@ -0,0 +1,35 @@
import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver';
import { Collection } from '../shared/collection.model';
import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils';
import { getTestScheduler } from 'jasmine-marbles';
import { CollectionBreadcrumbResolver } from './collection-breadcrumb.resolver';
describe('DSOBreadcrumbResolver', () => {
describe('resolve', () => {
let resolver: DSOBreadcrumbResolver<Collection>;
let collectionService: any;
let dsoBreadcrumbService: any;
let testCollection: Collection;
let uuid;
let breadcrumbUrl;
let currentUrl;
beforeEach(() => {
uuid = '1234-65487-12354-1235';
breadcrumbUrl = '/collections/' + uuid;
currentUrl = breadcrumbUrl + '/edit';
testCollection = Object.assign(new Collection(), { uuid });
dsoBreadcrumbService = {};
collectionService = {
findById: (id: string) => createSuccessfulRemoteDataObject$(testCollection)
};
resolver = new CollectionBreadcrumbResolver(dsoBreadcrumbService, collectionService);
});
it('should resolve a breadcrumb config for the correct DSO', () => {
const resolvedConfig = resolver.resolve({ params: { id: uuid } } as any, { url: currentUrl } as any);
const expectedConfig = { provider: dsoBreadcrumbService, key: testCollection, url: breadcrumbUrl };
getTestScheduler().expectObservable(resolvedConfig).toBe('(a|)', { a: expectedConfig})
});
});
});

View File

@@ -0,0 +1,46 @@
import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model';
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
import { DSOBreadcrumbsService } from './dso-breadcrumbs.service';
import { DataService } from '../data/data.service';
import { getRemoteDataPayload, getSucceededRemoteData } from '../shared/operators';
import { map } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { DSpaceObject } from '../shared/dspace-object.model';
import { ChildHALResource } from '../shared/child-hal-resource.model';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
/**
* The class that resolves the BreadcrumbConfig object for a DSpaceObject
*/
@Injectable()
export abstract class DSOBreadcrumbResolver<T extends ChildHALResource & DSpaceObject> implements Resolve<BreadcrumbConfig<T>> {
constructor(protected breadcrumbService: DSOBreadcrumbsService, protected dataService: DataService<T>) {
}
/**
* Method for resolving a breadcrumb config object
* @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
* @param {RouterStateSnapshot} state The current RouterStateSnapshot
* @returns BreadcrumbConfig object
*/
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<BreadcrumbConfig<T>> {
const uuid = route.params.id;
return this.dataService.findById(uuid, ...this.followLinks).pipe(
getSucceededRemoteData(),
getRemoteDataPayload(),
map((object: T) => {
const fullPath = state.url;
const url = fullPath.substr(0, fullPath.indexOf(uuid)) + uuid;
return { provider: this.breadcrumbService, key: object, url: url };
})
);
}
/**
* Method that returns the follow links to already resolve
* The self links defined in this list are expected to be requested somewhere in the near future
* Requesting them as embeds will limit the number of requests
*/
abstract get followLinks(): Array<FollowLinkConfig<T>>;
}

View File

@@ -0,0 +1,122 @@
import { async, TestBed } from '@angular/core/testing';
import { DSOBreadcrumbsService } from './dso-breadcrumbs.service';
import { getMockLinkService } from '../../shared/mocks/mock-link-service';
import { LinkService } from '../cache/builders/link.service';
import { Item } from '../shared/item.model';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils';
import { DSpaceObject } from '../shared/dspace-object.model';
import { of as observableOf } from 'rxjs';
import { Community } from '../shared/community.model';
import { Collection } from '../shared/collection.model';
import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model';
import { getTestScheduler } from 'jasmine-marbles';
import { getDSOPath } from '../../app-routing.module';
import { DSONameService } from './dso-name.service';
describe('DSOBreadcrumbsService', () => {
let service: DSOBreadcrumbsService;
let linkService: any;
let testItem;
let testCollection;
let testCommunity;
let itemPath;
let collectionPath;
let communityPath;
let itemUUID;
let collectionUUID;
let communityUUID;
let dsoNameService;
function init() {
itemPath = '/items/';
collectionPath = '/collection/';
communityPath = '/community/';
itemUUID = '04dd18fc-03f9-4b9a-9304-ed7c313686d3';
collectionUUID = '91dfa5b5-5440-4fb4-b869-02610342f886';
communityUUID = '6c0bfa6b-ce82-4bf4-a2a8-fd7682c567e8';
testCommunity = Object.assign(new Community(),
{
type: 'community',
metadata: {
'dc.title': [{value: 'community'}]
},
uuid: communityUUID,
parentCommunity: observableOf(Object.assign(createSuccessfulRemoteDataObject(undefined), { statusCode: 204 })),
_links: {
parentCommunity: 'site',
self: communityPath + communityUUID
}
}
);
testCollection = Object.assign(new Collection(),
{
type: 'collection',
metadata: {
'dc.title': [{value: 'collection'}]
},
uuid: collectionUUID,
parentCommunity: createSuccessfulRemoteDataObject$(testCommunity),
_links: {
parentCommunity: communityPath + communityUUID,
self: communityPath + collectionUUID
}
}
);
testItem = Object.assign(new Item(),
{
type: 'item',
metadata: {
'dc.title': [{value: 'item'}]
},
uuid: itemUUID,
owningCollection: createSuccessfulRemoteDataObject$(testCollection),
_links: {
owningCollection: collectionPath + collectionUUID,
self: itemPath + itemUUID
}
}
);
dsoNameService = { getName: (dso) => getName(dso) }
}
beforeEach(async(() => {
init();
TestBed.configureTestingModule({
providers: [
{ provide: LinkService, useValue: getMockLinkService() },
{ provide: DSONameService, useValue: dsoNameService }
]
}).compileComponents();
}));
beforeEach(() => {
linkService = TestBed.get(LinkService);
linkService.resolveLink.and.callFake((object, link) => object);
service = new DSOBreadcrumbsService(linkService, dsoNameService);
});
describe('getBreadcrumbs', () => {
it('should return the breadcrumbs based on an Item', () => {
const breadcrumbs = service.getBreadcrumbs(testItem, testItem._links.self);
const expectedCrumbs = [
new Breadcrumb(getName(testCommunity), getDSOPath(testCommunity)),
new Breadcrumb(getName(testCollection), getDSOPath(testCollection)),
new Breadcrumb(getName(testItem), getDSOPath(testItem)),
];
getTestScheduler().expectObservable(breadcrumbs).toBe('(a|)', { a: expectedCrumbs });
})
});
function getName(dso: DSpaceObject): string {
return dso.metadata['dc.title'][0].value
}
});

View File

@@ -0,0 +1,50 @@
import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model';
import { BreadcrumbsService } from './breadcrumbs.service';
import { DSONameService } from './dso-name.service';
import { Observable, of as observableOf } from 'rxjs';
import { ChildHALResource } from '../shared/child-hal-resource.model';
import { LinkService } from '../cache/builders/link.service';
import { DSpaceObject } from '../shared/dspace-object.model';
import { followLink } from '../../shared/utils/follow-link-config.model';
import { find, map, switchMap } from 'rxjs/operators';
import { getDSOPath } from '../../app-routing.module';
import { RemoteData } from '../data/remote-data';
import { hasValue } from '../../shared/empty.util';
import { Injectable } from '@angular/core';
/**
* Service to calculate DSpaceObject breadcrumbs for a single part of the route
*/
@Injectable()
export class DSOBreadcrumbsService implements BreadcrumbsService<ChildHALResource & DSpaceObject> {
constructor(
private linkService: LinkService,
private dsoNameService: DSONameService
) {
}
/**
* Method to recursively calculate the breadcrumbs
* This method returns the name and url of the key and all its parent DSO's recursively, top down
* @param key The key (a DSpaceObject) used to resolve the breadcrumb
* @param url The url to use as a link for this breadcrumb
*/
getBreadcrumbs(key: ChildHALResource & DSpaceObject, url: string): Observable<Breadcrumb[]> {
const label = this.dsoNameService.getName(key);
const crumb = new Breadcrumb(label, url);
const propertyName = key.getParentLinkKey();
return this.linkService.resolveLink(key, followLink(propertyName))[propertyName].pipe(
find((parentRD: RemoteData<ChildHALResource & DSpaceObject>) => parentRD.hasSucceeded || parentRD.statusCode === 204),
switchMap((parentRD: RemoteData<ChildHALResource & DSpaceObject>) => {
if (hasValue(parentRD.payload)) {
const parent = parentRD.payload;
return this.getBreadcrumbs(parent, getDSOPath(parent))
}
return observableOf([]);
}),
map((breadcrumbs: Breadcrumb[]) => [...breadcrumbs, crumb])
);
}
}

View File

@@ -0,0 +1,116 @@
import { ListableObject } from '../../shared/object-collection/shared/listable-object.model';
import { DSpaceObject } from '../shared/dspace-object.model';
import { GenericConstructor } from '../shared/generic-constructor';
import { Item } from '../shared/item.model';
import { MetadataValueFilter } from '../shared/metadata.models';
import { DSONameService } from './dso-name.service';
describe(`DSONameService`, () => {
let service: DSONameService;
let mockPersonName: string;
let mockPerson: DSpaceObject;
let mockOrgUnitName: string;
let mockOrgUnit: DSpaceObject;
let mockDSOName: string;
let mockDSO: DSpaceObject;
beforeEach(() => {
mockPersonName = 'Doe, John';
mockPerson = Object.assign(new DSpaceObject(), {
firstMetadataValue(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): string {
return mockPersonName
},
getRenderTypes(): Array<string | GenericConstructor<ListableObject>> {
return ['Person', Item, DSpaceObject];
}
});
mockOrgUnitName = 'Molecular Spectroscopy';
mockOrgUnit = Object.assign(new DSpaceObject(), {
firstMetadataValue(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): string {
return mockOrgUnitName
},
getRenderTypes(): Array<string | GenericConstructor<ListableObject>> {
return ['OrgUnit', Item, DSpaceObject];
}
});
mockDSOName = 'Lorem Ipsum';
mockDSO = Object.assign(new DSpaceObject(), {
firstMetadataValue(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): string {
return mockDSOName
},
getRenderTypes(): Array<string | GenericConstructor<ListableObject>> {
return [DSpaceObject];
}
});
service = new DSONameService();
});
describe(`getName`, () => {
it(`should use the Person factory for Person entities`, () => {
spyOn((service as any).factories, 'Person').and.returnValue('Bingo!');
const result = service.getName(mockPerson);
expect((service as any).factories.Person).toHaveBeenCalledWith(mockPerson);
expect(result).toBe('Bingo!');
});
it(`should use the OrgUnit factory for OrgUnit entities`, () => {
spyOn((service as any).factories, 'OrgUnit').and.returnValue('Bingo!');
const result = service.getName(mockOrgUnit);
expect((service as any).factories.OrgUnit).toHaveBeenCalledWith(mockOrgUnit);
expect(result).toBe('Bingo!');
});
it(`should use the Default factory for regular DSpaceObjects`, () => {
spyOn((service as any).factories, 'Default').and.returnValue('Bingo!');
const result = service.getName(mockDSO);
expect((service as any).factories.Default).toHaveBeenCalledWith(mockDSO);
expect(result).toBe('Bingo!');
});
});
describe(`factories.Person`, () => {
beforeEach(() => {
spyOn(mockPerson, 'firstMetadataValue').and.returnValues(...mockPersonName.split(', '));
});
it(`should return 'person.familyName, person.givenName'`, () => {
const result = (service as any).factories.Person(mockPerson);
expect(result).toBe(mockPersonName);
expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.familyName');
expect(mockPerson.firstMetadataValue).toHaveBeenCalledWith('person.givenName');
});
});
describe(`factories.OrgUnit`, () => {
beforeEach(() => {
spyOn(mockOrgUnit, 'firstMetadataValue').and.callThrough();
});
it(`should return 'organization.legalName'`, () => {
const result = (service as any).factories.OrgUnit(mockOrgUnit);
expect(result).toBe(mockOrgUnitName);
expect(mockOrgUnit.firstMetadataValue).toHaveBeenCalledWith('organization.legalName');
});
});
describe(`factories.Default`, () => {
beforeEach(() => {
spyOn(mockDSO, 'firstMetadataValue').and.callThrough();
});
it(`should return 'dc.title'`, () => {
const result = (service as any).factories.Default(mockDSO);
expect(result).toBe(mockDSOName);
expect(mockDSO.firstMetadataValue).toHaveBeenCalledWith('dc.title');
});
});
});

View File

@@ -0,0 +1,53 @@
import { Injectable } from '@angular/core';
import { hasValue } from '../../shared/empty.util';
import { DSpaceObject } from '../shared/dspace-object.model';
/**
* Returns a name for a {@link DSpaceObject} based
* on its render types.
*/
@Injectable({
providedIn: 'root'
})
export class DSONameService {
/**
* Functions to generate the specific names.
*
* If this list ever expands it will probably be worth it to
* refactor this using decorators for specific entity types,
* or perhaps by using a dedicated model for each entity type
*
* With only two exceptions those solutions seem overkill for now.
*/
private factories = {
Person: (dso: DSpaceObject): string => {
return `${dso.firstMetadataValue('person.familyName')}, ${dso.firstMetadataValue('person.givenName')}`;
},
OrgUnit: (dso: DSpaceObject): string => {
return dso.firstMetadataValue('organization.legalName');
},
Default: (dso: DSpaceObject): string => {
return dso.firstMetadataValue('dc.title');
}
};
/**
* Get the name for the given {@link DSpaceObject}
*
* @param dso The {@link DSpaceObject} you want a name for
*/
getName(dso: DSpaceObject): string {
const types = dso.getRenderTypes();
const match = types
.filter((type) => typeof type === 'string')
.find((type: string) => Object.keys(this.factories).includes(type)) as string;
if (hasValue(match)) {
return this.factories[match](dso);
} else {
return this.factories.Default(dso);
}
}
}

View File

@@ -0,0 +1,28 @@
import { I18nBreadcrumbResolver } from './i18n-breadcrumb.resolver';
describe('I18nBreadcrumbResolver', () => {
describe('resolve', () => {
let resolver: I18nBreadcrumbResolver;
let i18nBreadcrumbService: any;
let i18nKey: string;
let path: string;
beforeEach(() => {
i18nKey = 'example.key';
path = 'rest.com/path/to/breadcrumb';
i18nBreadcrumbService = {};
resolver = new I18nBreadcrumbResolver(i18nBreadcrumbService);
});
it('should resolve the breadcrumb config', () => {
const resolvedConfig = resolver.resolve({ data: { breadcrumbKey: i18nKey }, url: [path] } as any, {} as any);
const expectedConfig = { provider: i18nBreadcrumbService, key: i18nKey, url: path };
expect(resolvedConfig).toEqual(expectedConfig);
});
it('should resolve throw an error when no breadcrumbKey is defined', () => {
expect(() => {
resolver.resolve({ data: {} } as any, undefined)
}).toThrow();
});
});
});

View File

@@ -0,0 +1,29 @@
import { BreadcrumbConfig } from '../../breadcrumbs/breadcrumb/breadcrumb-config.model';
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router';
import { I18nBreadcrumbsService } from './i18n-breadcrumbs.service';
import { hasNoValue } from '../../shared/empty.util';
/**
* The class that resolves a BreadcrumbConfig object with an i18n key string for a route
*/
@Injectable()
export class I18nBreadcrumbResolver implements Resolve<BreadcrumbConfig<string>> {
constructor(protected breadcrumbService: I18nBreadcrumbsService) {
}
/**
* Method for resolving an I18n breadcrumb configuration object
* @param {ActivatedRouteSnapshot} route The current ActivatedRouteSnapshot
* @param {RouterStateSnapshot} state The current RouterStateSnapshot
* @returns BreadcrumbConfig object
*/
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): BreadcrumbConfig<string> {
const key = route.data.breadcrumbKey;
if (hasNoValue(key)) {
throw new Error('You provided an i18nBreadcrumbResolver for url \"' + route.url + '\" but no breadcrumbKey in the route\'s data')
}
const fullPath = route.url.join('');
return { provider: this.breadcrumbService, key: key, url: fullPath };
}
}

View File

@@ -0,0 +1,31 @@
import { async, TestBed } from '@angular/core/testing';
import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model';
import { getTestScheduler } from 'jasmine-marbles';
import { BREADCRUMB_MESSAGE_POSTFIX, I18nBreadcrumbsService } from './i18n-breadcrumbs.service';
describe('I18nBreadcrumbsService', () => {
let service: I18nBreadcrumbsService;
let exampleString;
let exampleURL;
function init() {
exampleString = 'example.string';
exampleURL = 'example.com';
}
beforeEach(async(() => {
init();
TestBed.configureTestingModule({}).compileComponents();
}));
beforeEach(() => {
service = new I18nBreadcrumbsService();
});
describe('getBreadcrumbs', () => {
it('should return a breadcrumb based on a string by adding the postfix', () => {
const breadcrumbs = service.getBreadcrumbs(exampleString, exampleURL);
getTestScheduler().expectObservable(breadcrumbs).toBe('(a|)', { a: [new Breadcrumb(exampleString + BREADCRUMB_MESSAGE_POSTFIX, exampleURL)] });
})
});
});

View File

@@ -0,0 +1,25 @@
import { Breadcrumb } from '../../breadcrumbs/breadcrumb/breadcrumb.model';
import { BreadcrumbsService } from './breadcrumbs.service';
import { Observable, of as observableOf } from 'rxjs';
import { Injectable } from '@angular/core';
/**
* The postfix for i18n breadcrumbs
*/
export const BREADCRUMB_MESSAGE_POSTFIX = '.breadcrumbs';
/**
* Service to calculate i18n breadcrumbs for a single part of the route
*/
@Injectable()
export class I18nBreadcrumbsService implements BreadcrumbsService<string> {
/**
* Method to calculate the breadcrumbs
* @param key The key used to resolve the breadcrumb
* @param url The url to use as a link for this breadcrumb
*/
getBreadcrumbs(key: string, url: string): Observable<Breadcrumb[]> {
return observableOf([new Breadcrumb(key + BREADCRUMB_MESSAGE_POSTFIX, url)]);
}
}

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@angular/core';
import { DSOBreadcrumbsService } from './dso-breadcrumbs.service';
import { ItemDataService } from '../data/item-data.service';
import { Item } from '../shared/item.model';
import { DSOBreadcrumbResolver } from './dso-breadcrumb.resolver';
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
/**
* The class that resolves the BreadcrumbConfig object for an Item
*/
@Injectable()
export class ItemBreadcrumbResolver extends DSOBreadcrumbResolver<Item> {
constructor(protected breadcrumbService: DSOBreadcrumbsService, protected dataService: ItemDataService) {
super(breadcrumbService, dataService);
}
/**
* Method that returns the follow links to already resolve
* The self links defined in this list are expected to be requested somewhere in the near future
* Requesting them as embeds will limit the number of requests
*/
get followLinks(): Array<FollowLinkConfig<Item>> {
return [
followLink('owningCollection', undefined,
followLink('parentCommunity', undefined,
followLink('parentCommunity'))
),
followLink('bundles'),
followLink('relationships')
];
}
}

View File

@@ -218,5 +218,52 @@ describe('LinkService', () => {
}); });
}); });
describe('when a link is missing', () => {
beforeEach(() => {
testModel = Object.assign(new TestModel(), {
value: 'a test value',
_links: {
self: {
href: 'http://self.link'
},
predecessor: {
href: 'http://predecessor.link'
}
}
});
spyOnFunction(decorators, 'getDataServiceFor').and.returnValue(TestDataService);
});
describe('resolving the available link', () => {
beforeEach(() => {
spyOnFunction(decorators, 'getLinkDefinition').and.returnValue({
resourceType: TEST_MODEL,
linkName: 'predecessor',
propertyName: 'predecessor'
});
result = service.resolveLinks(testModel, followLink('predecessor'));
});
it('should return the model with the resolved link', () => {
expect(result.predecessor).toBe('findByHref');
});
});
describe('resolving the missing link', () => {
beforeEach(() => {
spyOnFunction(decorators, 'getLinkDefinition').and.returnValue({
resourceType: TEST_MODEL,
linkName: 'successor',
propertyName: 'successor'
});
result = service.resolveLinks(testModel, followLink('successor'));
});
it('should return the model with no resolved link', () => {
expect(result.successor).toBeUndefined();
});
});
});
}); });
/* tslint:enable:max-classes-per-file */ /* tslint:enable:max-classes-per-file */

View File

@@ -55,16 +55,19 @@ export class LinkService {
parent: this.parentInjector parent: this.parentInjector
}).get(provider); }).get(provider);
const href = model._links[matchingLinkDef.linkName].href; const link = model._links[matchingLinkDef.linkName];
if (hasValue(link)) {
const href = link.href;
try { try {
if (matchingLinkDef.isList) { if (matchingLinkDef.isList) {
model[linkToFollow.name] = service.findAllByHref(href, linkToFollow.findListOptions, ...linkToFollow.linksToFollow); model[linkToFollow.name] = service.findAllByHref(href, linkToFollow.findListOptions, ...linkToFollow.linksToFollow);
} else { } else {
model[linkToFollow.name] = service.findByHref(href, ...linkToFollow.linksToFollow); model[linkToFollow.name] = service.findByHref(href, ...linkToFollow.linksToFollow);
}
} catch (e) {
throw new Error(`Something went wrong when using @dataService(${matchingLinkDef.resourceType.value}) ${hasValue(service) ? '' : '(undefined) '}to resolve link ${linkToFollow.name} from ${href}`);
} }
} catch (e) {
throw new Error(`Something went wrong when using @dataService(${matchingLinkDef.resourceType.value}) ${hasValue(service) ? '' : '(undefined) '}to resolve link ${linkToFollow.name} from ${href}`);
} }
} }
return model; return model;

View File

@@ -96,13 +96,14 @@ export class RemoteDataBuildService {
const responsePending = hasValue(reqEntry) && hasValue(reqEntry.responsePending) ? reqEntry.responsePending : false; const responsePending = hasValue(reqEntry) && hasValue(reqEntry.responsePending) ? reqEntry.responsePending : false;
let isSuccessful: boolean; let isSuccessful: boolean;
let error: RemoteDataError; let error: RemoteDataError;
if (hasValue(reqEntry) && hasValue(reqEntry.response)) { const response = reqEntry ? reqEntry.response : undefined;
isSuccessful = reqEntry.response.isSuccessful; if (hasValue(response)) {
const errorMessage = isSuccessful === false ? (reqEntry.response as ErrorResponse).errorMessage : undefined; isSuccessful = response.isSuccessful;
const errorMessage = isSuccessful === false ? (response as ErrorResponse).errorMessage : undefined;
if (hasValue(errorMessage)) { if (hasValue(errorMessage)) {
error = new RemoteDataError( error = new RemoteDataError(
(reqEntry.response as ErrorResponse).statusCode, response.statusCode,
(reqEntry.response as ErrorResponse).statusText, response.statusText,
errorMessage errorMessage
); );
} }
@@ -112,7 +113,9 @@ export class RemoteDataBuildService {
responsePending, responsePending,
isSuccessful, isSuccessful,
error, error,
payload payload,
hasValue(response) ? response.statusCode : undefined
); );
}) })
); );

View File

@@ -3,7 +3,7 @@ import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { Observable } from 'rxjs/internal/Observable'; import { Observable } from 'rxjs/internal/Observable';
import { map, switchMap } from 'rxjs/operators'; import { map, switchMap } from 'rxjs/operators';
import { hasValue } from '../../shared/empty.util'; import { hasValue, isNotEmpty } from '../../shared/empty.util';
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 { dataService } from '../cache/builders/build-decorators';
@@ -72,7 +72,7 @@ export class BitstreamDataService extends DataService<Bitstream> {
public getThumbnailFor(item: Item): Observable<RemoteData<Bitstream>> { public getThumbnailFor(item: Item): Observable<RemoteData<Bitstream>> {
return this.bundleService.findByItemAndName(item, 'THUMBNAIL').pipe( return this.bundleService.findByItemAndName(item, 'THUMBNAIL').pipe(
switchMap((bundleRD: RemoteData<Bundle>) => { switchMap((bundleRD: RemoteData<Bundle>) => {
if (hasValue(bundleRD.payload)) { if (isNotEmpty(bundleRD.payload)) {
return this.findAllByBundle(bundleRD.payload, { elementsPerPage: 1 }).pipe( return this.findAllByBundle(bundleRD.payload, { elementsPerPage: 1 }).pipe(
map((bitstreamRD: RemoteData<PaginatedList<Bitstream>>) => { map((bitstreamRD: RemoteData<PaginatedList<Bitstream>>) => {
if (hasValue(bitstreamRD.payload) && hasValue(bitstreamRD.payload.page)) { if (hasValue(bitstreamRD.payload) && hasValue(bitstreamRD.payload.page)) {
@@ -108,7 +108,7 @@ export class BitstreamDataService extends DataService<Bitstream> {
public getMatchingThumbnail(item: Item, bitstreamInOriginal: Bitstream): Observable<RemoteData<Bitstream>> { public getMatchingThumbnail(item: Item, bitstreamInOriginal: Bitstream): Observable<RemoteData<Bitstream>> {
return this.bundleService.findByItemAndName(item, 'THUMBNAIL').pipe( return this.bundleService.findByItemAndName(item, 'THUMBNAIL').pipe(
switchMap((bundleRD: RemoteData<Bundle>) => { switchMap((bundleRD: RemoteData<Bundle>) => {
if (hasValue(bundleRD.payload)) { if (isNotEmpty(bundleRD.payload)) {
return this.findAllByBundle(bundleRD.payload, { elementsPerPage: Number.MAX_SAFE_INTEGER }).pipe( return this.findAllByBundle(bundleRD.payload, { elementsPerPage: Number.MAX_SAFE_INTEGER }).pipe(
map((bitstreamRD: RemoteData<PaginatedList<Bitstream>>) => { map((bitstreamRD: RemoteData<PaginatedList<Bitstream>>) => {
if (hasValue(bitstreamRD.payload) && hasValue(bitstreamRD.payload.page)) { if (hasValue(bitstreamRD.payload) && hasValue(bitstreamRD.payload.page)) {

View File

@@ -282,7 +282,7 @@ describe('BitstreamFormatDataService', () => {
format.id = 'format-id'; format.id = 'format-id';
const expected = cold('(b|)', {b: true}); const expected = cold('(b|)', {b: true});
const result = service.delete(format); const result = service.delete(format.id);
expect(result).toBeObservable(expected); expect(result).toBeObservable(expected);
}); });

View File

@@ -154,19 +154,19 @@ export class BitstreamFormatDataService extends DataService<BitstreamFormat> {
/** /**
* Delete an existing DSpace Object on the server * Delete an existing DSpace Object on the server
* @param format The DSpace Object to be removed * @param formatID The DSpace Object'id to be removed
* Return an observable that emits true when the deletion was successful, false when it failed * Return an observable that emits true when the deletion was successful, false when it failed
*/ */
delete(format: BitstreamFormat): Observable<boolean> { delete(formatID: string): Observable<boolean> {
const requestId = this.requestService.generateRequestId(); const requestId = this.requestService.generateRequestId();
const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( const hrefObs = this.halService.getEndpoint(this.linkPath).pipe(
map((endpoint: string) => this.getIDHref(endpoint, format.id))); map((endpoint: string) => this.getIDHref(endpoint, formatID)));
hrefObs.pipe( hrefObs.pipe(
find((href: string) => hasValue(href)), find((href: string) => hasValue(href)),
map((href: string) => { map((href: string) => {
const request = new DeleteByIDRequest(requestId, href, format.id); const request = new DeleteByIDRequest(requestId, href, formatID);
this.requestService.configure(request); this.requestService.configure(request);
}) })
).subscribe(); ).subscribe();

View File

@@ -152,7 +152,11 @@ export abstract class DataService<T extends CacheableObject> {
/** /**
* Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded
* info should be added to the objects * info should be added to the objects
*
* @param options Find list options object
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved * @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 = {}, ...linksToFollow: Array<FollowLinkConfig<T>>): Observable<RemoteData<PaginatedList<T>>> { findAll(options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<T>>): Observable<RemoteData<PaginatedList<T>>> {
return this.findList(this.getFindAllHref(options), options, ...linksToFollow); return this.findList(this.getFindAllHref(options), options, ...linksToFollow);
@@ -162,6 +166,7 @@ export abstract class DataService<T extends CacheableObject> {
* Returns an observable of {@link RemoteData} of an object, based on href observable, * Returns an observable of {@link RemoteData} of an object, based on href observable,
* with a list of {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object * with a list of {@link FollowLinkConfig}, to automatically resolve {@link HALLink}s of the object
* @param href$ Observable of href of object we want to retrieve * @param href$ Observable of href of object we want to retrieve
* @param options Find list options object
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/ */
protected findList(href$, options: FindListOptions, ...linksToFollow: Array<FollowLinkConfig<T>>) { protected findList(href$, options: FindListOptions, ...linksToFollow: Array<FollowLinkConfig<T>>) {
@@ -231,6 +236,7 @@ export abstract class DataService<T extends CacheableObject> {
* Returns a list of observables of {@link RemoteData} of objects, based on an href, with a list of {@link FollowLinkConfig}, * 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 * to automatically resolve {@link HALLink}s of the object
* @param href The url of object we want to retrieve * @param href The url of object we want to retrieve
* @param findListOptions Find list options object
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/ */
findAllByHref(href: string, findListOptions: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<T>>): Observable<RemoteData<PaginatedList<T>>> { findAllByHref(href: string, findListOptions: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<T>>): Observable<RemoteData<PaginatedList<T>>> {
@@ -259,6 +265,7 @@ export abstract class DataService<T extends CacheableObject> {
* *
* @param searchMethod The search method for the object * @param searchMethod The search method for the object
* @param options The [[FindListOptions]] object * @param options The [[FindListOptions]] object
* @param linksToFollow The array of [[FollowLinkConfig]]
* @return {Observable<RemoteData<PaginatedList<T>>} * @return {Observable<RemoteData<PaginatedList<T>>}
* Return an observable that emits response from the server * Return an observable that emits response from the server
*/ */
@@ -367,16 +374,16 @@ export abstract class DataService<T extends CacheableObject> {
/** /**
* Delete an existing DSpace Object on the server * Delete an existing DSpace Object on the server
* @param dso The DSpace Object to be removed * @param dsoID The DSpace Object' id to be removed
* @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual * @param copyVirtualMetadata (optional parameter) the identifiers of the relationship types for which the virtual
* metadata should be saved as real metadata * metadata should be saved as real metadata
* @return an observable that emits true when the deletion was successful, false when it failed * @return an observable that emits true when the deletion was successful, false when it failed
*/ */
delete(dso: T, copyVirtualMetadata?: string[]): Observable<boolean> { delete(dsoID: string, copyVirtualMetadata?: string[]): Observable<boolean> {
const requestId = this.requestService.generateRequestId(); const requestId = this.requestService.generateRequestId();
const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( const hrefObs = this.halService.getEndpoint(this.linkPath).pipe(
map((endpoint: string) => this.getIDHref(endpoint, dso.uuid))); map((endpoint: string) => this.getIDHref(endpoint, dsoID)));
hrefObs.pipe( hrefObs.pipe(
find((href: string) => hasValue(href)), find((href: string) => hasValue(href)),
@@ -388,7 +395,7 @@ export abstract class DataService<T extends CacheableObject> {
+ id + id
); );
} }
const request = new DeleteByIDRequest(requestId, href, dso.uuid); const request = new DeleteByIDRequest(requestId, href, dsoID);
this.requestService.configure(request); this.requestService.configure(request);
}) })
).subscribe(); ).subscribe();

View File

@@ -17,7 +17,8 @@ export class RemoteData<T> {
private responsePending?: boolean, private responsePending?: boolean,
private isSuccessful?: boolean, private isSuccessful?: boolean,
public error?: RemoteDataError, public error?: RemoteDataError,
public payload?: T public payload?: T,
public statusCode?: number,
) { ) {
} }

View File

@@ -18,6 +18,8 @@ import { RemoteData } from '../data/remote-data';
import { PaginatedList } from '../data/paginated-list'; import { PaginatedList } from '../data/paginated-list';
import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationsService } from '../../shared/notifications/notifications.service';
import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
import { dataService } from '../cache/builders/build-decorators';
import { GROUP } from './models/group.resource-type';
/** /**
* Provides methods to retrieve eperson group resources. * Provides methods to retrieve eperson group resources.
@@ -25,6 +27,7 @@ import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
@dataService(GROUP)
export class GroupDataService extends DataService<Group> { export class GroupDataService extends DataService<Group> {
protected linkPath = 'groups'; protected linkPath = 'groups';
protected browseEndpoint = ''; protected browseEndpoint = '';

View File

@@ -10,6 +10,7 @@ import { catchError, distinctUntilKeyChanged, filter, first, map, take } from 'r
import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; import { GLOBAL_CONFIG, GlobalConfig } from '../../../config';
import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { hasValue, isNotEmpty } from '../../shared/empty.util';
import { DSONameService } from '../breadcrumbs/dso-name.service';
import { CacheableObject } from '../cache/object-cache.reducer'; import { CacheableObject } from '../cache/object-cache.reducer';
import { BitstreamDataService } from '../data/bitstream-data.service'; import { BitstreamDataService } from '../data/bitstream-data.service';
import { BitstreamFormatDataService } from '../data/bitstream-format-data.service'; import { BitstreamFormatDataService } from '../data/bitstream-format-data.service';
@@ -35,6 +36,7 @@ export class MetadataService {
private translate: TranslateService, private translate: TranslateService,
private meta: Meta, private meta: Meta,
private title: Title, private title: Title,
private dsoNameService: DSONameService,
private bitstreamDataService: BitstreamDataService, private bitstreamDataService: BitstreamDataService,
private bitstreamFormatDataService: BitstreamFormatDataService, private bitstreamFormatDataService: BitstreamFormatDataService,
@Inject(GLOBAL_CONFIG) private envConfig: GlobalConfig @Inject(GLOBAL_CONFIG) private envConfig: GlobalConfig
@@ -154,7 +156,7 @@ export class MetadataService {
* Add <meta name="title" ... > to the <head> * Add <meta name="title" ... > to the <head>
*/ */
private setTitleTag(): void { private setTitleTag(): void {
const value = this.getMetaTagValue('dc.title'); const value = this.dsoNameService.getName(this.currentObject.getValue());
this.addMetaTag('title', value); this.addMetaTag('title', value);
this.title.setTitle(value); this.title.setTitle(value);
} }

View File

@@ -0,0 +1,12 @@
import { HALResource } from './hal-resource.model';
/**
* Interface for HALResources with a parent object link
*/
export interface ChildHALResource extends HALResource {
/**
* Returns the key of the parent link
*/
getParentLinkKey(): keyof this['_links'];
}

View File

@@ -12,10 +12,13 @@ import { License } from './license.model';
import { LICENSE } from './license.resource-type'; import { LICENSE } from './license.resource-type';
import { ResourcePolicy } from './resource-policy.model'; import { ResourcePolicy } from './resource-policy.model';
import { RESOURCE_POLICY } from './resource-policy.resource-type'; import { RESOURCE_POLICY } from './resource-policy.resource-type';
import { COMMUNITY } from './community.resource-type';
import { Community } from './community.model';
import { ChildHALResource } from './child-hal-resource.model';
@typedObject @typedObject
@inheritSerialization(DSpaceObject) @inheritSerialization(DSpaceObject)
export class Collection extends DSpaceObject { export class Collection extends DSpaceObject implements ChildHALResource {
static type = COLLECTION; static type = COLLECTION;
/** /**
@@ -35,6 +38,7 @@ export class Collection extends DSpaceObject {
itemtemplate: HALLink; itemtemplate: HALLink;
defaultAccessConditions: HALLink; defaultAccessConditions: HALLink;
logo: HALLink; logo: HALLink;
parentCommunity: HALLink;
self: HALLink; self: HALLink;
}; };
@@ -59,6 +63,13 @@ export class Collection extends DSpaceObject {
@link(RESOURCE_POLICY, true) @link(RESOURCE_POLICY, true)
defaultAccessConditions?: Observable<RemoteData<PaginatedList<ResourcePolicy>>>; defaultAccessConditions?: Observable<RemoteData<PaginatedList<ResourcePolicy>>>;
/**
* The Community that is a direct parent of this Collection
* Will be undefined unless the parent community HALLink has been resolved.
*/
@link(COMMUNITY, false)
parentCommunity?: Observable<RemoteData<Community>>;
/** /**
* The introductory text of this Collection * The introductory text of this Collection
* Corresponds to the metadata field dc.description * Corresponds to the metadata field dc.description
@@ -98,4 +109,8 @@ export class Collection extends DSpaceObject {
get sidebarText(): string { get sidebarText(): string {
return this.firstMetadataValue('dc.description.tableofcontents'); return this.firstMetadataValue('dc.description.tableofcontents');
} }
getParentLinkKey(): keyof this['_links'] {
return 'parentCommunity';
}
} }

View File

@@ -10,10 +10,11 @@ import { COLLECTION } from './collection.resource-type';
import { COMMUNITY } from './community.resource-type'; import { COMMUNITY } from './community.resource-type';
import { DSpaceObject } from './dspace-object.model'; import { DSpaceObject } from './dspace-object.model';
import { HALLink } from './hal-link.model'; import { HALLink } from './hal-link.model';
import { ChildHALResource } from './child-hal-resource.model';
@typedObject @typedObject
@inheritSerialization(DSpaceObject) @inheritSerialization(DSpaceObject)
export class Community extends DSpaceObject { export class Community extends DSpaceObject implements ChildHALResource {
static type = COMMUNITY; static type = COMMUNITY;
/** /**
@@ -30,6 +31,7 @@ export class Community extends DSpaceObject {
collections: HALLink; collections: HALLink;
logo: HALLink; logo: HALLink;
subcommunities: HALLink; subcommunities: HALLink;
parentCommunity: HALLink;
self: HALLink; self: HALLink;
}; };
@@ -54,6 +56,13 @@ export class Community extends DSpaceObject {
@link(COMMUNITY, true) @link(COMMUNITY, true)
subcommunities?: Observable<RemoteData<PaginatedList<Community>>>; subcommunities?: Observable<RemoteData<PaginatedList<Community>>>;
/**
* The Community that is a direct parent of this Community
* Will be undefined unless the parent community HALLink has been resolved.
*/
@link(COMMUNITY, false)
parentCommunity?: Observable<RemoteData<Community>>;
/** /**
* The introductory text of this Community * The introductory text of this Community
* Corresponds to the metadata field dc.description * Corresponds to the metadata field dc.description
@@ -85,4 +94,8 @@ export class Community extends DSpaceObject {
get sidebarText(): string { get sidebarText(): string {
return this.firstMetadataValue('dc.description.tableofcontents'); return this.firstMetadataValue('dc.description.tableofcontents');
} }
getParentLinkKey(): keyof this['_links'] {
return 'parentCommunity';
}
} }

View File

@@ -69,6 +69,7 @@ export class DSpaceObject extends ListableObject implements CacheableObject {
/** /**
* The name for this DSpaceObject * The name for this DSpaceObject
* @deprecated use {@link DSONameService} instead
*/ */
get name(): string { get name(): string {
return (isUndefined(this._name)) ? this.firstMetadataValue('dc.title') : this._name; return (isUndefined(this._name)) ? this.firstMetadataValue('dc.title') : this._name;

View File

@@ -17,13 +17,14 @@ import { HALLink } from './hal-link.model';
import { Relationship } from './item-relationships/relationship.model'; import { Relationship } from './item-relationships/relationship.model';
import { RELATIONSHIP } from './item-relationships/relationship.resource-type'; import { RELATIONSHIP } from './item-relationships/relationship.resource-type';
import { ITEM } from './item.resource-type'; import { ITEM } from './item.resource-type';
import { ChildHALResource } from './child-hal-resource.model';
/** /**
* Class representing a DSpace Item * Class representing a DSpace Item
*/ */
@typedObject @typedObject
@inheritSerialization(DSpaceObject) @inheritSerialization(DSpaceObject)
export class Item extends DSpaceObject { export class Item extends DSpaceObject implements ChildHALResource {
static type = ITEM; static type = ITEM;
/** /**
@@ -100,4 +101,8 @@ export class Item extends DSpaceObject {
} }
return [entityType, ...super.getRenderTypes()]; return [entityType, ...super.getRenderTypes()];
} }
getParentLinkKey(): keyof this['_links'] {
return 'owningCollection';
}
} }

View File

@@ -2,7 +2,7 @@ import { combineLatest as observableCombineLatest, Observable, of as observableO
import { Injectable, OnDestroy } from '@angular/core'; import { Injectable, OnDestroy } from '@angular/core';
import { NavigationExtras, Router } from '@angular/router'; import { NavigationExtras, Router } from '@angular/router';
import { first, map, switchMap, tap } from 'rxjs/operators'; import { first, map, switchMap, tap } from 'rxjs/operators';
import { followLink } from '../../../shared/utils/follow-link-config.model'; import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link-config.model';
import { LinkService } from '../../cache/builders/link.service'; import { LinkService } from '../../cache/builders/link.service';
import { FacetConfigSuccessResponse, FacetValueSuccessResponse, SearchSuccessResponse } from '../../cache/response.models'; import { FacetConfigSuccessResponse, FacetValueSuccessResponse, SearchSuccessResponse } from '../../cache/response.models';
import { PaginatedList } from '../../data/paginated-list'; import { PaginatedList } from '../../data/paginated-list';
@@ -107,10 +107,11 @@ export class SearchService implements OnDestroy {
* Method to retrieve a paginated list of search results from the server * Method to retrieve a paginated list of search results from the server
* @param {PaginatedSearchOptions} searchOptions The configuration necessary to perform this search * @param {PaginatedSearchOptions} searchOptions The configuration necessary to perform this search
* @param responseMsToLive The amount of milliseconds for the response to live in cache * @param responseMsToLive The amount of milliseconds for the response to live in cache
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
* @returns {Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>>} Emits a paginated list with all search results found * @returns {Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>>} Emits a paginated list with all search results found
*/ */
search(searchOptions?: PaginatedSearchOptions, responseMsToLive?: number): Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>> { search<T extends DSpaceObject>(searchOptions?: PaginatedSearchOptions, responseMsToLive?: number, ...linksToFollow: Array<FollowLinkConfig<T>>): Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>> {
return this.getPaginatedResults(this.searchEntries(searchOptions)); return this.getPaginatedResults<T>(this.searchEntries(searchOptions), ...linksToFollow);
} }
/** /**
@@ -151,9 +152,10 @@ export class SearchService implements OnDestroy {
/** /**
* Method to convert the parsed responses into a paginated list of search results * Method to convert the parsed responses into a paginated list of search results
* @param searchEntries: The request entries from the search method * @param searchEntries: The request entries from the search method
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
* @returns {Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>>} Emits a paginated list with all search results found * @returns {Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>>} Emits a paginated list with all search results found
*/ */
getPaginatedResults(searchEntries: Observable<{ searchOptions: PaginatedSearchOptions, requestEntry: RequestEntry }>): Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>> { getPaginatedResults<T extends DSpaceObject>(searchEntries: Observable<{ searchOptions: PaginatedSearchOptions, requestEntry: RequestEntry }>, ...linksToFollow: Array<FollowLinkConfig<T>>): Observable<RemoteData<PaginatedList<SearchResult<DSpaceObject>>>> {
const requestEntryObs: Observable<RequestEntry> = searchEntries.pipe( const requestEntryObs: Observable<RequestEntry> = searchEntries.pipe(
map((entry) => entry.requestEntry), map((entry) => entry.requestEntry),
); );
@@ -174,7 +176,7 @@ export class SearchService implements OnDestroy {
}), }),
// Send a request for each item to ensure fresh cache // Send a request for each item to ensure fresh cache
tap((reqs: RestRequest[]) => reqs.forEach((req: RestRequest) => this.requestService.configure(req))), tap((reqs: RestRequest[]) => reqs.forEach((req: RestRequest) => this.requestService.configure(req))),
map((reqs: RestRequest[]) => reqs.map((req: RestRequest) => this.rdb.buildSingle(req.href))), map((reqs: RestRequest[]) => reqs.map((req: RestRequest) => this.rdb.buildSingle(req.href, ...linksToFollow))),
switchMap((input: Array<Observable<RemoteData<DSpaceObject>>>) => this.rdb.aggregate(input)), switchMap((input: Array<Observable<RemoteData<DSpaceObject>>>) => this.rdb.aggregate(input)),
); );

View File

@@ -1,6 +1,5 @@
import { inheritSerialization } from 'cerialize'; import { inheritSerialization } from 'cerialize';
import { typedObject } from '../../cache/builders/build-decorators'; import { inheritLinkAnnotations, typedObject } from '../../cache/builders/build-decorators';
import { DSpaceObject } from '../../shared/dspace-object.model';
import { CLAIMED_TASK } from './claimed-task-object.resource-type'; import { CLAIMED_TASK } from './claimed-task-object.resource-type';
import { TaskObject } from './task-object.model'; import { TaskObject } from './task-object.model';
@@ -8,7 +7,8 @@ import { TaskObject } from './task-object.model';
* A model class for a ClaimedTask. * A model class for a ClaimedTask.
*/ */
@typedObject @typedObject
@inheritSerialization(DSpaceObject) @inheritSerialization(TaskObject)
@inheritLinkAnnotations(TaskObject)
export class ClaimedTask extends TaskObject { export class ClaimedTask extends TaskObject {
static type = CLAIMED_TASK; static type = CLAIMED_TASK;
} }

View File

@@ -1,5 +1,5 @@
import { inheritSerialization } from 'cerialize'; import { inheritSerialization } from 'cerialize';
import { typedObject } from '../../cache/builders/build-decorators'; import { inheritLinkAnnotations, typedObject } from '../../cache/builders/build-decorators';
import { POOL_TASK } from './pool-task-object.resource-type'; import { POOL_TASK } from './pool-task-object.resource-type';
import { TaskObject } from './task-object.model'; import { TaskObject } from './task-object.model';
@@ -8,6 +8,7 @@ import { TaskObject } from './task-object.model';
*/ */
@typedObject @typedObject
@inheritSerialization(TaskObject) @inheritSerialization(TaskObject)
@inheritLinkAnnotations(TaskObject)
export class PoolTask extends TaskObject { export class PoolTask extends TaskObject {
static type = POOL_TASK; static type = POOL_TASK;
} }

View File

@@ -12,6 +12,7 @@ import { DSpaceObject } from '../../shared/dspace-object.model';
import { HALLink } from '../../shared/hal-link.model'; import { HALLink } from '../../shared/hal-link.model';
import { WorkflowItem } from '../../submission/models/workflowitem.model'; import { WorkflowItem } from '../../submission/models/workflowitem.model';
import { TASK_OBJECT } from './task-object.resource-type'; import { TASK_OBJECT } from './task-object.resource-type';
import { WORKFLOWITEM } from '../../eperson/models/workflowitem.resource-type';
/** /**
* An abstract model class for a TaskObject. * An abstract model class for a TaskObject.
@@ -45,7 +46,7 @@ export class TaskObject extends DSpaceObject implements CacheableObject {
@deserialize @deserialize
_links: { _links: {
self: HALLink; self: HALLink;
eperson: HALLink; owner: HALLink;
group: HALLink; group: HALLink;
workflowitem: HALLink; workflowitem: HALLink;
}; };
@@ -54,7 +55,7 @@ export class TaskObject extends DSpaceObject implements CacheableObject {
* The EPerson for this task * The EPerson for this task
* Will be undefined unless the eperson {@link HALLink} has been resolved. * Will be undefined unless the eperson {@link HALLink} has been resolved.
*/ */
@link(EPERSON) @link(EPERSON, false, 'owner')
eperson?: Observable<RemoteData<EPerson>>; eperson?: Observable<RemoteData<EPerson>>;
/** /**
@@ -68,7 +69,7 @@ export class TaskObject extends DSpaceObject implements CacheableObject {
* The WorkflowItem for this task * The WorkflowItem for this task
* Will be undefined unless the workflowitem {@link HALLink} has been resolved. * Will be undefined unless the workflowitem {@link HALLink} has been resolved.
*/ */
@link(WorkflowItem.type) @link(WORKFLOWITEM)
workflowitem?: Observable<RemoteData<WorkflowItem>> | WorkflowItem; workflowitem?: Observable<RemoteData<WorkflowItem>> | WorkflowItem;
} }

View File

@@ -23,16 +23,16 @@ describe('OrgUnitItemMetadataListElementComponent', () => {
declarations: [OrgUnitItemMetadataListElementComponent], declarations: [OrgUnitItemMetadataListElementComponent],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(OrgUnitItemMetadataListElementComponent, { }).overrideComponent(OrgUnitItemMetadataListElementComponent, {
// set: { changeDetection: ChangeDetectionStrategy.Default } set: { changeDetection: ChangeDetectionStrategy.Default }
}).compileComponents(); }).compileComponents();
})); }));
beforeEach(async(() => { beforeEach(() => {
fixture = TestBed.createComponent(OrgUnitItemMetadataListElementComponent); fixture = TestBed.createComponent(OrgUnitItemMetadataListElementComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
comp.metadataRepresentation = mockItemMetadataRepresentation; comp.metadataRepresentation = mockItemMetadataRepresentation;
fixture.detectChanges(); fixture.detectChanges();
})); });
it('should show the name of the organisation as a link', () => { it('should show the name of the organisation as a link', () => {
const linkText = fixture.debugElement.query(By.css('a')).nativeElement.textContent; const linkText = fixture.debugElement.query(By.css('a')).nativeElement.textContent;

View File

@@ -29,12 +29,12 @@ describe('PersonItemMetadataListElementComponent', () => {
}).compileComponents(); }).compileComponents();
})); }));
beforeEach(async(() => { beforeEach(() => {
fixture = TestBed.createComponent(PersonItemMetadataListElementComponent); fixture = TestBed.createComponent(PersonItemMetadataListElementComponent);
comp = fixture.componentInstance; comp = fixture.componentInstance;
comp.metadataRepresentation = mockItemMetadataRepresentation; comp.metadataRepresentation = mockItemMetadataRepresentation;
fixture.detectChanges(); fixture.detectChanges();
})); });
it('should show the person\'s name as a link', () => { it('should show the person\'s name as a link', () => {
const linkText = fixture.debugElement.query(By.css('a')).nativeElement.textContent; const linkText = fixture.debugElement.query(By.css('a')).nativeElement.textContent;

View File

@@ -125,7 +125,7 @@ describe('DeleteComColPageComponent', () => {
it('should call delete on the data service', () => { it('should call delete on the data service', () => {
comp.onConfirm(data1); comp.onConfirm(data1);
fixture.detectChanges(); fixture.detectChanges();
expect(dsoDataService.delete).toHaveBeenCalledWith(data1); expect(dsoDataService.delete).toHaveBeenCalledWith(data1.id);
}); });
}); });

View File

@@ -43,7 +43,7 @@ export class DeleteComColPageComponent<TDomain extends DSpaceObject> implements
* Deletes an existing DSO and redirects to the home page afterwards, showing a notification that states whether or not the deletion was successful * Deletes an existing DSO and redirects to the home page afterwards, showing a notification that states whether or not the deletion was successful
*/ */
onConfirm(dso: TDomain) { onConfirm(dso: TDomain) {
this.dsoDataService.delete(dso) this.dsoDataService.delete(dso.id)
.pipe(first()) .pipe(first())
.subscribe((success: boolean) => { .subscribe((success: boolean) => {
if (success) { if (success) {

View File

@@ -12,7 +12,7 @@ import { filter, map, startWith, tap } from 'rxjs/operators';
import { getCollectionPageRoute } from '../../+collection-page/collection-page-routing.module'; import { getCollectionPageRoute } from '../../+collection-page/collection-page-routing.module';
import { getCommunityPageRoute } from '../../+community-page/community-page-routing.module'; import { getCommunityPageRoute } from '../../+community-page/community-page-routing.module';
import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; import { GLOBAL_CONFIG, GlobalConfig } from '../../../config';
import { Router, ActivatedRoute, RouterModule, UrlSegment } from '@angular/router'; import { Router, ActivatedRoute, RouterModule, UrlSegment, Params } from '@angular/router';
import { BrowseByTypeConfig } from '../../../config/browse-by-type-config.interface'; import { BrowseByTypeConfig } from '../../../config/browse-by-type-config.interface';
import { hasValue } from '../empty.util'; import { hasValue } from '../empty.util';
@@ -76,9 +76,8 @@ export class ComcolPageBrowseByComponent implements OnInit {
}, ...this.allOptions ]; }, ...this.allOptions ];
} }
this.currentOptionId$ = this.route.url.pipe( this.currentOptionId$ = this.route.params.pipe(
filter((urlSegments: UrlSegment[]) => hasValue(urlSegments)), map((params: Params) => params.id)
map((urlSegments: UrlSegment[]) => urlSegments[urlSegments.length - 1].path)
); );
} }

View File

@@ -49,7 +49,7 @@ export abstract class FieldParser {
label: this.configData.label, label: this.configData.label,
initialCount: this.getInitArrayIndex(), initialCount: this.getInitArrayIndex(),
notRepeatable: !this.configData.repeatable, notRepeatable: !this.configData.repeatable,
required: isNotEmpty(this.configData.mandatory), required: JSON.parse( this.configData.mandatory),
groupFactory: () => { groupFactory: () => {
let model; let model;
if ((arrayCounter === 0)) { if ((arrayCounter === 0)) {

View File

@@ -2,7 +2,7 @@
<a [class.disabled]="!(object.workflowitem | async)?.hasSucceeded" <a [class.disabled]="!(object.workflowitem | async)?.hasSucceeded"
class="btn btn-primary mt-1 mb-3" class="btn btn-primary mt-1 mb-3"
ngbTooltip="{{'submission.workflow.tasks.claimed.edit_help' | translate}}" ngbTooltip="{{'submission.workflow.tasks.claimed.edit_help' | translate}}"
[routerLink]="['/workflowitems/' + (object.workflowitem | async)?.payload.id + '/edit']" [routerLink]="['/workflowitems/' + (object?.workflowitem | async)?.payload?.id + '/edit']"
role="button"> role="button">
<i class="fa fa-edit"></i> {{'submission.workflow.tasks.claimed.edit' | translate}} <i class="fa fa-edit"></i> {{'submission.workflow.tasks.claimed.edit' | translate}}
</a> </a>

View File

@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core'; import { ChangeDetectionStrategy, Injector, NO_ERRORS_SCHEMA } from '@angular/core';
import { async, ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { By } from '@angular/platform-browser'; import { By } from '@angular/platform-browser';
@@ -141,7 +141,7 @@ describe('WorkspaceitemActionsComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
fixture.whenStable().then(() => { fixture.whenStable().then(() => {
expect(mockDataService.delete).toHaveBeenCalledWith(mockObject); expect(mockDataService.delete).toHaveBeenCalledWith(mockObject.id);
}); });
}); });

View File

@@ -1,4 +1,4 @@
import { Component, Injector, Input, OnDestroy } from '@angular/core'; import { Component, Injector, Input } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject } from 'rxjs';
@@ -62,7 +62,7 @@ export class WorkspaceitemActionsComponent extends MyDSpaceActionsComponent<Work
(result) => { (result) => {
if (result === 'ok') { if (result === 'ok') {
this.processingDelete$.next(true); this.processingDelete$.next(true);
this.objectDataService.delete(this.object) this.objectDataService.delete(this.object.id)
.subscribe((response: boolean) => { .subscribe((response: boolean) => {
this.processingDelete$.next(false); this.processingDelete$.next(false);
this.handleActionResponse(response); this.handleActionResponse(response);

View File

@@ -1,8 +1,10 @@
<ds-item-detail-preview *ngIf="workflowitem" <ng-container *ngVar="(workflowitemRD$ | async)?.payload as workflowitem">
[item]="(workflowitem.item | async)?.payload" <ds-item-detail-preview *ngIf="workflowitem"
[object]="object" [item]="(workflowitem.item | async)?.payload"
[showSubmitter]="showSubmitter" [object]="object"
[status]="status"> [showSubmitter]="showSubmitter"
</ds-item-detail-preview> [status]="status">
</ds-item-detail-preview>
<ds-claimed-task-actions *ngIf="workflowitem" [object]="dso"></ds-claimed-task-actions> <ds-claimed-task-actions *ngIf="workflowitem" [object]="dso"></ds-claimed-task-actions>
</ng-container>

View File

@@ -11,6 +11,9 @@ import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspa
import { WorkflowItem } from '../../../../core/submission/models/workflowitem.model'; import { WorkflowItem } from '../../../../core/submission/models/workflowitem.model';
import { createSuccessfulRemoteDataObject } from '../../../testing/utils'; import { createSuccessfulRemoteDataObject } from '../../../testing/utils';
import { ClaimedTaskSearchResult } from '../../../object-collection/shared/claimed-task-search-result.model'; import { ClaimedTaskSearchResult } from '../../../object-collection/shared/claimed-task-search-result.model';
import { VarDirective } from '../../../utils/var.directive';
import { getMockLinkService } from '../../../mocks/mock-link-service';
import { LinkService } from '../../../../core/cache/builders/link.service';
let component: ClaimedTaskSearchResultDetailElementComponent; let component: ClaimedTaskSearchResultDetailElementComponent;
let fixture: ComponentFixture<ClaimedTaskSearchResultDetailElementComponent>; let fixture: ComponentFixture<ClaimedTaskSearchResultDetailElementComponent>;
@@ -53,12 +56,16 @@ const rdItem = createSuccessfulRemoteDataObject(item);
const workflowitem = Object.assign(new WorkflowItem(), { item: observableOf(rdItem) }); const workflowitem = Object.assign(new WorkflowItem(), { item: observableOf(rdItem) });
const rdWorkflowitem = createSuccessfulRemoteDataObject(workflowitem); const rdWorkflowitem = createSuccessfulRemoteDataObject(workflowitem);
mockResultObject.indexableObject = Object.assign(new ClaimedTask(), { workflowitem: observableOf(rdWorkflowitem) }); mockResultObject.indexableObject = Object.assign(new ClaimedTask(), { workflowitem: observableOf(rdWorkflowitem) });
const linkService = getMockLinkService();
describe('ClaimedTaskSearchResultDetailElementComponent', () => { describe('ClaimedTaskSearchResultDetailElementComponent', () => {
beforeEach(async(() => { beforeEach(async(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [NoopAnimationsModule], imports: [NoopAnimationsModule],
declarations: [ClaimedTaskSearchResultDetailElementComponent], declarations: [ClaimedTaskSearchResultDetailElementComponent, VarDirective],
providers: [
{ provide: LinkService, useValue: linkService }
],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(ClaimedTaskSearchResultDetailElementComponent, { }).overrideComponent(ClaimedTaskSearchResultDetailElementComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default } set: { changeDetection: ChangeDetectionStrategy.Default }
@@ -75,8 +82,12 @@ describe('ClaimedTaskSearchResultDetailElementComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it('should init item properly', () => { it('should init workflowitem properly', (done) => {
expect(component.workflowitem).toEqual(workflowitem); component.workflowitemRD$.subscribe((workflowitemRD) => {
expect(linkService.resolveLink).toHaveBeenCalled();
expect(workflowitemRD.payload).toEqual(workflowitem);
done();
});
}); });
it('should have properly status', () => { it('should have properly status', () => {

View File

@@ -1,17 +1,17 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { find } from 'rxjs/operators';
import { RemoteData } from '../../../../core/data/remote-data'; import { RemoteData } from '../../../../core/data/remote-data';
import { ViewMode } from '../../../../core/shared/view-mode.model'; import { ViewMode } from '../../../../core/shared/view-mode.model';
import { isNotUndefined } from '../../../empty.util';
import { WorkflowItem } from '../../../../core/submission/models/workflowitem.model'; import { WorkflowItem } from '../../../../core/submission/models/workflowitem.model';
import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model';
import { SearchResultDetailElementComponent } from '../search-result-detail-element.component'; import { SearchResultDetailElementComponent } from '../search-result-detail-element.component';
import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type';
import { listableObjectComponent } from '../../../object-collection/shared/listable-object/listable-object.decorator'; import { listableObjectComponent } from '../../../object-collection/shared/listable-object/listable-object.decorator';
import { ClaimedTaskSearchResult } from '../../../object-collection/shared/claimed-task-search-result.model'; import { ClaimedTaskSearchResult } from '../../../object-collection/shared/claimed-task-search-result.model';
import { followLink } from '../../../utils/follow-link-config.model';
import { LinkService } from '../../../../core/cache/builders/link.service';
/** /**
* This component renders claimed task object for the search result in the detail view. * This component renders claimed task object for the search result in the detail view.
@@ -38,25 +38,24 @@ export class ClaimedTaskSearchResultDetailElementComponent extends SearchResultD
/** /**
* The workflowitem object that belonging to the result object * The workflowitem object that belonging to the result object
*/ */
public workflowitem: WorkflowItem; public workflowitemRD$: Observable<RemoteData<WorkflowItem>>;
constructor(protected linkService: LinkService) {
super();
}
/** /**
* Initialize all instance variables * Initialize all instance variables
*/ */
ngOnInit() { ngOnInit() {
super.ngOnInit(); super.ngOnInit();
this.initWorkflowItem(this.dso.workflowitem as Observable<RemoteData<WorkflowItem>>); this.linkService.resolveLink(this.dso, followLink(
} 'workflowitem',
null,
/** followLink('item', null, followLink('bundles')),
* Retrieve workflow item from result object followLink('submitter')
*/ ));
initWorkflowItem(wfi$: Observable<RemoteData<WorkflowItem>>) { this.workflowitemRD$ = this.dso.workflowitem as Observable<RemoteData<WorkflowItem>>;
wfi$.pipe(
find((rd: RemoteData<WorkflowItem>) => (rd.hasSucceeded && isNotUndefined(rd.payload)))
).subscribe((rd: RemoteData<WorkflowItem>) => {
this.workflowitem = rd.payload;
});
} }
} }

View File

@@ -10,9 +10,9 @@
<div class="row mb-1"> <div class="row mb-1">
<div class="col-xs-12 col-md-4"> <div class="col-xs-12 col-md-4">
<ds-metadata-field-wrapper> <ds-metadata-field-wrapper>
<ds-thumbnail [thumbnail]="thumbnail$ | async"></ds-thumbnail> <ds-thumbnail [thumbnail]="getThumbnail() | async"></ds-thumbnail>
</ds-metadata-field-wrapper> </ds-metadata-field-wrapper>
<ng-container *ngVar="(bitstreams$ | async) as bitstreams"> <ng-container *ngVar="(getFiles() | async) as bitstreams">
<ds-metadata-field-wrapper [label]="('item.page.files' | translate)"> <ds-metadata-field-wrapper [label]="('item.page.files' | translate)">
<div *ngIf="bitstreams?.length > 0" class="file-section"> <div *ngIf="bitstreams?.length > 0" class="file-section">
<button class="btn btn-link" *ngFor="let file of bitstreams; let last=last;" (click)="downloadBitstreamFile(file?.uuid)"> <button class="btn btn-link" *ngFor="let file of bitstreams; let last=last;" (click)="downloadBitstreamFile(file?.uuid)">

View File

@@ -5,8 +5,8 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { of as observableOf } from 'rxjs'; import { Observable, of as observableOf } from 'rxjs';
import { Observable } from 'rxjs/internal/Observable';
import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service'; import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../../../../core/cache/object-cache.service'; import { ObjectCacheService } from '../../../../core/cache/object-cache.service';
import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; import { BitstreamDataService } from '../../../../core/data/bitstream-data.service';
@@ -28,7 +28,6 @@ import { HALEndpointServiceStub } from '../../../testing/hal-endpoint-service-st
import { createSuccessfulRemoteDataObject$ } from '../../../testing/utils'; import { createSuccessfulRemoteDataObject$ } from '../../../testing/utils';
import { FileSizePipe } from '../../../utils/file-size-pipe'; import { FileSizePipe } from '../../../utils/file-size-pipe';
import { FollowLinkConfig } from '../../../utils/follow-link-config.model'; import { FollowLinkConfig } from '../../../utils/follow-link-config.model';
import { TruncatePipe } from '../../../utils/truncate.pipe'; import { TruncatePipe } from '../../../utils/truncate.pipe';
import { VarDirective } from '../../../utils/var.directive'; import { VarDirective } from '../../../utils/var.directive';
import { ItemDetailPreviewFieldComponent } from './item-detail-preview-field/item-detail-preview-field.component'; import { ItemDetailPreviewFieldComponent } from './item-detail-preview-field/item-detail-preview-field.component';
@@ -127,8 +126,17 @@ describe('ItemDetailPreviewComponent', () => {
})); }));
it('should init thumbnail and bitstreams on init', () => { it('should get item thumbnail', (done) => {
expect(component.thumbnail$).toBeDefined(); component.getThumbnail().subscribe((thumbnail) => {
expect(component.bitstreams$).toBeDefined(); expect(thumbnail).toBeDefined();
done();
});
});
it('should get item bitstreams', (done) => {
component.getFiles().subscribe((bitstreams) => {
expect(bitstreams).toBeDefined();
done();
})
}); });
}); });

View File

@@ -1,17 +1,13 @@
import { Component, Input } from '@angular/core'; import { Component, Input } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { first, map } from 'rxjs/operators'; import { first } from 'rxjs/operators';
import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; import { BitstreamDataService } from '../../../../core/data/bitstream-data.service';
import { RemoteData } from '../../../../core/data/remote-data';
import { Item } from '../../../../core/shared/item.model'; import { Item } from '../../../../core/shared/item.model';
import { import {
getAllSucceededRemoteListPayload,
getFirstSucceededRemoteDataPayload, getFirstSucceededRemoteDataPayload,
getFirstSucceededRemoteListPayload, getFirstSucceededRemoteListPayload
getRemoteDataPayload,
getSucceededRemoteData
} from '../../../../core/shared/operators'; } from '../../../../core/shared/operators';
import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type';
import { fadeInOut } from '../../../animations/fade'; import { fadeInOut } from '../../../animations/fade';
@@ -71,20 +67,13 @@ export class ItemDetailPreviewComponent {
* *
* @param {FileService} fileService * @param {FileService} fileService
* @param {HALEndpointService} halService * @param {HALEndpointService} halService
* @param {BitstreamDataService} bitstreamDataService
*/ */
constructor(private fileService: FileService, constructor(private fileService: FileService,
private halService: HALEndpointService, private halService: HALEndpointService,
private bitstreamDataService: BitstreamDataService) { private bitstreamDataService: BitstreamDataService) {
} }
/**
* Initialize all instance variables
*/
ngOnInit() {
this.thumbnail$ = this.getThumbnail();
this.bitstreams$ = this.getFiles();
}
/** /**
* Perform bitstream download * Perform bitstream download
*/ */
@@ -98,14 +87,14 @@ export class ItemDetailPreviewComponent {
} }
// TODO refactor this method to return RemoteData, and the template to deal with loading and errors // TODO refactor this method to return RemoteData, and the template to deal with loading and errors
getThumbnail(): Observable<Bitstream> { public getThumbnail(): Observable<Bitstream> {
return this.bitstreamDataService.getThumbnailFor(this.item).pipe( return this.bitstreamDataService.getThumbnailFor(this.item).pipe(
getFirstSucceededRemoteDataPayload() getFirstSucceededRemoteDataPayload()
); );
} }
// TODO refactor this method to return RemoteData, and the template to deal with loading and errors // TODO refactor this method to return RemoteData, and the template to deal with loading and errors
getFiles(): Observable<Bitstream[]> { public getFiles(): Observable<Bitstream[]> {
return this.bitstreamDataService return this.bitstreamDataService
.findAllByItemAndBundleName(this.item, 'ORIGINAL', { elementsPerPage: Number.MAX_SAFE_INTEGER }) .findAllByItemAndBundleName(this.item, 'ORIGINAL', { elementsPerPage: Number.MAX_SAFE_INTEGER })
.pipe( .pipe(

View File

@@ -1,7 +1,9 @@
<ds-item-detail-preview *ngIf="workflowitem" <ng-container *ngVar="(workflowitemRD$ | async)?.payload as workflowitem">
[item]="(workflowitem.item | async)?.payload" <ds-item-detail-preview *ngIf="workflowitem"
[object]="object" [item]="(workflowitem?.item | async)?.payload"
[showSubmitter]="showSubmitter" [object]="object"
[status]="status"></ds-item-detail-preview> [showSubmitter]="showSubmitter"
[status]="status"></ds-item-detail-preview>
<ds-pool-task-actions *ngIf="workflowitem" [object]="dso"></ds-pool-task-actions> <ds-pool-task-actions *ngIf="workflowitem" [object]="dso"></ds-pool-task-actions>
</ng-container>

View File

@@ -11,6 +11,9 @@ import { WorkflowItem } from '../../../../core/submission/models/workflowitem.mo
import { createSuccessfulRemoteDataObject } from '../../../testing/utils'; import { createSuccessfulRemoteDataObject } from '../../../testing/utils';
import { PoolSearchResultDetailElementComponent } from './pool-search-result-detail-element.component'; import { PoolSearchResultDetailElementComponent } from './pool-search-result-detail-element.component';
import { PoolTaskSearchResult } from '../../../object-collection/shared/pool-task-search-result.model'; import { PoolTaskSearchResult } from '../../../object-collection/shared/pool-task-search-result.model';
import { VarDirective } from '../../../utils/var.directive';
import { LinkService } from '../../../../core/cache/builders/link.service';
import { getMockLinkService } from '../../../mocks/mock-link-service';
let component: PoolSearchResultDetailElementComponent; let component: PoolSearchResultDetailElementComponent;
let fixture: ComponentFixture<PoolSearchResultDetailElementComponent>; let fixture: ComponentFixture<PoolSearchResultDetailElementComponent>;
@@ -53,15 +56,17 @@ const rdItem = createSuccessfulRemoteDataObject(item);
const workflowitem = Object.assign(new WorkflowItem(), { item: observableOf(rdItem) }); const workflowitem = Object.assign(new WorkflowItem(), { item: observableOf(rdItem) });
const rdWorkflowitem = createSuccessfulRemoteDataObject(workflowitem); const rdWorkflowitem = createSuccessfulRemoteDataObject(workflowitem);
mockResultObject.indexableObject = Object.assign(new PoolTask(), { workflowitem: observableOf(rdWorkflowitem) }); mockResultObject.indexableObject = Object.assign(new PoolTask(), { workflowitem: observableOf(rdWorkflowitem) });
const linkService = getMockLinkService();
describe('PoolSearchResultDetailElementComponent', () => { describe('PoolSearchResultDetailElementComponent', () => {
beforeEach(async(() => { beforeEach(async(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [NoopAnimationsModule], imports: [NoopAnimationsModule],
declarations: [PoolSearchResultDetailElementComponent], declarations: [PoolSearchResultDetailElementComponent, VarDirective],
providers: [ providers: [
{ provide: 'objectElementProvider', useValue: (mockResultObject) }, { provide: 'objectElementProvider', useValue: (mockResultObject) },
{ provide: 'indexElementProvider', useValue: (compIndex) } { provide: 'indexElementProvider', useValue: (compIndex) },
{ provide: LinkService, useValue: linkService }
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(PoolSearchResultDetailElementComponent, { }).overrideComponent(PoolSearchResultDetailElementComponent, {
@@ -79,8 +84,12 @@ describe('PoolSearchResultDetailElementComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it('should init item properly', () => { it('should init workflowitem properly', (done) => {
expect(component.workflowitem).toEqual(workflowitem); component.workflowitemRD$.subscribe((workflowitemRD) => {
expect(linkService.resolveLink).toHaveBeenCalled();
expect(workflowitemRD.payload).toEqual(workflowitem);
done();
});
}); });
it('should have properly status', () => { it('should have properly status', () => {

View File

@@ -1,9 +1,7 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { find } from 'rxjs/operators';
import { RemoteData } from '../../../../core/data/remote-data'; import { RemoteData } from '../../../../core/data/remote-data';
import { isNotUndefined } from '../../../empty.util';
import { PoolTask } from '../../../../core/tasks/models/pool-task-object.model'; import { PoolTask } from '../../../../core/tasks/models/pool-task-object.model';
import { SearchResultDetailElementComponent } from '../search-result-detail-element.component'; import { SearchResultDetailElementComponent } from '../search-result-detail-element.component';
import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type';
@@ -11,6 +9,8 @@ import { WorkflowItem } from '../../../../core/submission/models/workflowitem.mo
import { ViewMode } from '../../../../core/shared/view-mode.model'; import { ViewMode } from '../../../../core/shared/view-mode.model';
import { listableObjectComponent } from '../../../object-collection/shared/listable-object/listable-object.decorator'; import { listableObjectComponent } from '../../../object-collection/shared/listable-object/listable-object.decorator';
import { PoolTaskSearchResult } from '../../../object-collection/shared/pool-task-search-result.model'; import { PoolTaskSearchResult } from '../../../object-collection/shared/pool-task-search-result.model';
import { followLink } from '../../../utils/follow-link-config.model';
import { LinkService } from '../../../../core/cache/builders/link.service';
/** /**
* This component renders pool task object for the search result in the detail view. * This component renders pool task object for the search result in the detail view.
@@ -37,25 +37,24 @@ export class PoolSearchResultDetailElementComponent extends SearchResultDetailEl
/** /**
* The workflowitem object that belonging to the result object * The workflowitem object that belonging to the result object
*/ */
public workflowitem: WorkflowItem; public workflowitemRD$: Observable<RemoteData<WorkflowItem>>;
constructor(protected linkService: LinkService) {
super();
}
/** /**
* Initialize all instance variables * Initialize all instance variables
*/ */
ngOnInit() { ngOnInit() {
super.ngOnInit(); super.ngOnInit();
this.initWorkflowItem(this.dso.workflowitem as Observable<RemoteData<WorkflowItem>>); this.linkService.resolveLink(this.dso, followLink(
} 'workflowitem',
null,
/** followLink('item', null, followLink('bundles')),
* Retrieve workflowitem from result object followLink('submitter')
*/ ));
initWorkflowItem(wfi$: Observable<RemoteData<WorkflowItem>>) { this.workflowitemRD$ = this.dso.workflowitem as Observable<RemoteData<WorkflowItem>>;
wfi$.pipe(
find((rd: RemoteData<WorkflowItem>) => (rd.hasSucceeded && isNotUndefined(rd.payload)))
).subscribe((rd: RemoteData<WorkflowItem>) => {
this.workflowitem = rd.payload;
});
} }
} }

View File

@@ -1,7 +1,10 @@
<ds-item-list-preview *ngIf="workflowitem" <ng-container *ngVar="(workflowitemRD$ | async)?.payload as workflowitem">
[item]="(workflowitem.item | async)?.payload" <ds-item-list-preview *ngIf="workflowitem"
[object]="object" [item]="(workflowitem?.item | async)?.payload"
[showSubmitter]="showSubmitter" [object]="object"
[status]="status"></ds-item-list-preview> [showSubmitter]="showSubmitter"
[status]="status"></ds-item-list-preview>
<ds-claimed-task-actions *ngIf="workflowitem" [object]="dso"></ds-claimed-task-actions>
</ng-container>
<ds-claimed-task-actions *ngIf="workflowitem" [object]="dso"></ds-claimed-task-actions>

View File

@@ -12,6 +12,9 @@ import { WorkflowItem } from '../../../../core/submission/models/workflowitem.mo
import { createSuccessfulRemoteDataObject } from '../../../testing/utils'; import { createSuccessfulRemoteDataObject } from '../../../testing/utils';
import { ClaimedTaskSearchResult } from '../../../object-collection/shared/claimed-task-search-result.model'; import { ClaimedTaskSearchResult } from '../../../object-collection/shared/claimed-task-search-result.model';
import { TruncatableService } from '../../../truncatable/truncatable.service'; import { TruncatableService } from '../../../truncatable/truncatable.service';
import { VarDirective } from '../../../utils/var.directive';
import { LinkService } from '../../../../core/cache/builders/link.service';
import { getMockLinkService } from '../../../mocks/mock-link-service';
let component: ClaimedSearchResultListElementComponent; let component: ClaimedSearchResultListElementComponent;
let fixture: ComponentFixture<ClaimedSearchResultListElementComponent>; let fixture: ComponentFixture<ClaimedSearchResultListElementComponent>;
@@ -54,14 +57,16 @@ const rdItem = createSuccessfulRemoteDataObject(item);
const workflowitem = Object.assign(new WorkflowItem(), { item: observableOf(rdItem) }); const workflowitem = Object.assign(new WorkflowItem(), { item: observableOf(rdItem) });
const rdWorkflowitem = createSuccessfulRemoteDataObject(workflowitem); const rdWorkflowitem = createSuccessfulRemoteDataObject(workflowitem);
mockResultObject.indexableObject = Object.assign(new ClaimedTask(), { workflowitem: observableOf(rdWorkflowitem) }); mockResultObject.indexableObject = Object.assign(new ClaimedTask(), { workflowitem: observableOf(rdWorkflowitem) });
const linkService = getMockLinkService();
describe('ClaimedSearchResultListElementComponent', () => { describe('ClaimedSearchResultListElementComponent', () => {
beforeEach(async(() => { beforeEach(async(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [NoopAnimationsModule], imports: [NoopAnimationsModule],
declarations: [ClaimedSearchResultListElementComponent], declarations: [ClaimedSearchResultListElementComponent, VarDirective],
providers: [ providers: [
{ provide: TruncatableService, useValue: {} }, { provide: TruncatableService, useValue: {} },
{ provide: LinkService, useValue: linkService }
], ],
schemas: [NO_ERRORS_SCHEMA] schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(ClaimedSearchResultListElementComponent, { }).overrideComponent(ClaimedSearchResultListElementComponent, {
@@ -79,8 +84,12 @@ describe('ClaimedSearchResultListElementComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
}); });
it('should init item properly', () => { it('should init workflowitem properly', (done) => {
expect(component.workflowitem).toEqual(workflowitem); component.workflowitemRD$.subscribe((workflowitemRD) => {
expect(linkService.resolveLink).toHaveBeenCalled();
expect(workflowitemRD.payload).toEqual(workflowitem);
done();
});
}); });
it('should have properly status', () => { it('should have properly status', () => {

View File

@@ -2,17 +2,18 @@ import { Component } from '@angular/core';
import { Location, LocationStrategy, PathLocationStrategy } from '@angular/common'; import { Location, LocationStrategy, PathLocationStrategy } from '@angular/common';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { find } from 'rxjs/operators';
import { ViewMode } from '../../../../core/shared/view-mode.model'; import { ViewMode } from '../../../../core/shared/view-mode.model';
import { RemoteData } from '../../../../core/data/remote-data'; import { RemoteData } from '../../../../core/data/remote-data';
import { isNotUndefined } from '../../../empty.util';
import { WorkflowItem } from '../../../../core/submission/models/workflowitem.model'; import { WorkflowItem } from '../../../../core/submission/models/workflowitem.model';
import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model';
import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type';
import { listableObjectComponent } from '../../../object-collection/shared/listable-object/listable-object.decorator'; import { listableObjectComponent } from '../../../object-collection/shared/listable-object/listable-object.decorator';
import { ClaimedTaskSearchResult } from '../../../object-collection/shared/claimed-task-search-result.model'; import { ClaimedTaskSearchResult } from '../../../object-collection/shared/claimed-task-search-result.model';
import { SearchResultListElementComponent } from '../../search-result-list-element/search-result-list-element.component'; import { SearchResultListElementComponent } from '../../search-result-list-element/search-result-list-element.component';
import { followLink } from '../../../utils/follow-link-config.model';
import { LinkService } from '../../../../core/cache/builders/link.service';
import { TruncatableService } from '../../../truncatable/truncatable.service';
/** /**
* This component renders claimed task object for the search result in the list view. * This component renders claimed task object for the search result in the list view.
@@ -40,24 +41,26 @@ export class ClaimedSearchResultListElementComponent extends SearchResultListEle
/** /**
* The workflowitem object that belonging to the result object * The workflowitem object that belonging to the result object
*/ */
public workflowitem: WorkflowItem; public workflowitemRD$: Observable<RemoteData<WorkflowItem>>;
constructor(
protected linkService: LinkService,
protected truncatableService: TruncatableService
) {
super(truncatableService);
}
/** /**
* Initialize all instance variables * Initialize all instance variables
*/ */
ngOnInit() { ngOnInit() {
super.ngOnInit(); super.ngOnInit();
this.initWorkflowItem(this.dso.workflowitem as Observable<RemoteData<WorkflowItem>>); this.linkService.resolveLink(this.dso, followLink(
} 'workflowitem',
null,
/** followLink('item'),
* Retrieve workflowitem from result object followLink('submitter')
*/ ));
initWorkflowItem(wfi$: Observable<RemoteData<WorkflowItem>>) { this.workflowitemRD$ = this.dso.workflowitem as Observable<RemoteData<WorkflowItem>>;
wfi$.pipe(
find((rd: RemoteData<WorkflowItem>) => (rd.hasSucceeded && isNotUndefined(rd.payload)))
).subscribe((rd: RemoteData<WorkflowItem>) => {
this.workflowitem = rd.payload;
});
} }
} }

View File

@@ -1,7 +1,9 @@
<ds-item-list-preview *ngIf="workflowitem" <ng-container *ngVar="(workflowitemRD$ | async)?.payload as workflowitem">
[item]="(workflowitem.item | async)?.payload" <ds-item-list-preview *ngIf="workflowitem"
[object]="object" [item]="(workflowitem?.item | async)?.payload"
[showSubmitter]="showSubmitter" [object]="object"
[status]="status"></ds-item-list-preview> [showSubmitter]="showSubmitter"
[status]="status"></ds-item-list-preview>
<ds-pool-task-actions [object]="dso"></ds-pool-task-actions> <ds-pool-task-actions *ngIf="workflowitem" [object]="dso"></ds-pool-task-actions>
</ng-container>

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