+
+ +
+
diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 7bc4ee1c5a..aaad66adf6 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -3,12 +3,11 @@ import { HttpClientModule } from '@angular/common/http'; import { NgModule } from '@angular/core'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; - import { EffectsModule } from '@ngrx/effects'; 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 { DYNAMIC_MATCHER_PROVIDERS } from '@ng-dynamic-forms/core'; import { TranslateModule } from '@ngx-translate/core'; import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to'; @@ -21,7 +20,7 @@ import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; 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 { 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 { NotificationsBoardComponent } from './shared/notifications/notifications-board/notifications-board.component'; import { SharedModule } from './shared/shared.module'; +import { BreadcrumbsComponent } from './breadcrumbs/breadcrumbs.component'; export function getConfig() { return ENV_CONFIG; @@ -97,7 +97,8 @@ const PROVIDERS = [ provide: RouterStateSerializer, useClass: DSpaceRouterStateSerializer }, - ClientCookieService + ClientCookieService, + ...DYNAMIC_MATCHER_PROVIDERS, ]; const DECLARATIONS = [ @@ -128,6 +129,7 @@ const EXPORTS = [ ], declarations: [ ...DECLARATIONS, + BreadcrumbsComponent, ], exports: [ ...EXPORTS diff --git a/src/app/breadcrumbs/breadcrumb/breadcrumb-config.model.ts b/src/app/breadcrumbs/breadcrumb/breadcrumb-config.model.ts new file mode 100644 index 0000000000..0ff8fc5033 --- /dev/null +++ b/src/app/breadcrumbs/breadcrumb/breadcrumb-config.model.ts @@ -0,0 +1,21 @@ +import { BreadcrumbsService } from '../../core/breadcrumbs/breadcrumbs.service'; + +/** + * Interface for breadcrumb configuration objects + */ +export interface BreadcrumbConfig { + /** + * The service used to calculate the breadcrumb object + */ + provider: BreadcrumbsService; + + /** + * The key that is used to calculate the breadcrumb display value + */ + key: T; + + /** + * The url of the breadcrumb + */ + url?: string; +} diff --git a/src/app/breadcrumbs/breadcrumb/breadcrumb.model.ts b/src/app/breadcrumbs/breadcrumb/breadcrumb.model.ts new file mode 100644 index 0000000000..c6ab8491b4 --- /dev/null +++ b/src/app/breadcrumbs/breadcrumb/breadcrumb.model.ts @@ -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) { + } +} diff --git a/src/app/breadcrumbs/breadcrumbs.component.html b/src/app/breadcrumbs/breadcrumbs.component.html new file mode 100644 index 0000000000..b773964d1e --- /dev/null +++ b/src/app/breadcrumbs/breadcrumbs.component.html @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/src/app/breadcrumbs/breadcrumbs.component.scss b/src/app/breadcrumbs/breadcrumbs.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/breadcrumbs/breadcrumbs.component.spec.ts b/src/app/breadcrumbs/breadcrumbs.component.spec.ts new file mode 100644 index 0000000000..0ab1fed208 --- /dev/null +++ b/src/app/breadcrumbs/breadcrumbs.component.spec.ts @@ -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 { + getBreadcrumbs(key: string, url: string): Observable { + return observableOf([new Breadcrumb(key, url)]); + } +} + +describe('BreadcrumbsComponent', () => { + let component: BreadcrumbsComponent; + let fixture: ComponentFixture; + let router: any; + let route: any; + let breadcrumbProvider; + let breadcrumbConfigA: BreadcrumbConfig; + let breadcrumbConfigB: BreadcrumbConfig; + 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 }) + }) + }) +}); diff --git a/src/app/breadcrumbs/breadcrumbs.component.ts b/src/app/breadcrumbs/breadcrumbs.component.ts new file mode 100644 index 0000000000..2bba3c76b6 --- /dev/null +++ b/src/app/breadcrumbs/breadcrumbs.component.ts @@ -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 { + 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; + } +} diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index 222214c76f..8773b1a9fb 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -1,7 +1,6 @@ import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs'; import { distinctUntilChanged, filter, map, mergeMap, tap } from 'rxjs/operators'; import { Inject, Injectable } from '@angular/core'; -import { EPersonDataService } from '../eperson/eperson-data.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RequestService } from '../data/request.service'; import { GLOBAL_CONFIG } from '../../../config'; diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts index d0969d38d4..2681ed39a2 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -1,9 +1,7 @@ // import @ngrx import { Action } from '@ngrx/store'; - // import type function import { type } from '../../shared/ngrx/type'; - // import models import { EPerson } from '../eperson/models/eperson.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; @@ -31,6 +29,9 @@ export const AuthActionTypes = { REGISTRATION_ERROR: type('dspace/auth/REGISTRATION_ERROR'), REGISTRATION_SUCCESS: type('dspace/auth/REGISTRATION_SUCCESS'), 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 */ @@ -76,11 +77,11 @@ export class AuthenticatedSuccessAction implements Action { payload: { authenticated: boolean; authToken: AuthTokenInfo; - user: EPerson + userHref: string }; - constructor(authenticated: boolean, authToken: AuthTokenInfo, user: EPerson) { - this.payload = { authenticated, authToken, user }; + constructor(authenticated: boolean, authToken: AuthTokenInfo, userHref: string) { + this.payload = { authenticated, authToken, userHref }; } } @@ -322,6 +323,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 */ /** @@ -343,4 +385,8 @@ export type AuthActions | RegistrationErrorAction | RegistrationSuccessAction | AddAuthenticationMessageAction - | ResetAuthenticationMessagesAction; + | ResetAuthenticationMessagesAction + | RetrieveAuthenticatedEpersonAction + | RetrieveAuthenticatedEpersonErrorAction + | RetrieveAuthenticatedEpersonSuccessAction + | SetRedirectUrlAction; diff --git a/src/app/core/auth/auth.effects.spec.ts b/src/app/core/auth/auth.effects.spec.ts index 8c2b4026e0..34b900fe7e 100644 --- a/src/app/core/auth/auth.effects.spec.ts +++ b/src/app/core/auth/auth.effects.spec.ts @@ -18,12 +18,14 @@ import { LogOutErrorAction, LogOutSuccessAction, RefreshTokenErrorAction, - RefreshTokenSuccessAction + RefreshTokenSuccessAction, + RetrieveAuthenticatedEpersonAction, + RetrieveAuthenticatedEpersonErrorAction, + RetrieveAuthenticatedEpersonSuccessAction } from './auth.actions'; import { AuthServiceStub } from '../../shared/testing/auth-service-stub'; import { AuthService } from './auth.service'; import { AuthState } from './auth.reducer'; - import { EPersonMock } from '../../shared/testing/eperson-mock'; describe('AuthEffects', () => { @@ -42,13 +44,14 @@ describe('AuthEffects', () => { authServiceStub = new AuthServiceStub(); token = authServiceStub.getToken(); } + beforeEach(() => { init(); TestBed.configureTestingModule({ providers: [ AuthEffects, - {provide: AuthService, useValue: authServiceStub}, - {provide: Store, useValue: store}, + { provide: AuthService, useValue: authServiceStub }, + { provide: Store, useValue: store }, provideMockActions(() => actions), // other providers ], @@ -63,11 +66,11 @@ describe('AuthEffects', () => { actions = hot('--a-', { a: { 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); }); @@ -80,11 +83,11 @@ describe('AuthEffects', () => { actions = hot('--a-', { a: { 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); }); @@ -94,9 +97,9 @@ describe('AuthEffects', () => { describe('authenticateSuccess$', () => { 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); }); @@ -106,9 +109,9 @@ describe('AuthEffects', () => { describe('when token is valid', () => { 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); }); @@ -118,23 +121,42 @@ describe('AuthEffects', () => { 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'))); - 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); }); }); }); + 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('when check token succeeded', () => { 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); }); @@ -144,23 +166,53 @@ describe('AuthEffects', () => { 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('')); - 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 CheckAuthenticationTokenErrorAction()}); + const expected = cold('--b-', { b: new CheckAuthenticationTokenErrorAction() }); expect(authEffects.checkToken$).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('when refresh token succeeded', () => { 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); }); @@ -170,9 +222,9 @@ describe('AuthEffects', () => { it('should return a REFRESH_TOKEN_ERROR action in response to a REFRESH_TOKEN action', () => { 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); }); @@ -184,9 +236,9 @@ describe('AuthEffects', () => { describe('when refresh token succeeded', () => { 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); }); @@ -196,9 +248,9 @@ describe('AuthEffects', () => { 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'))); - 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); }); diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 1e68802af8..5ee63ccd92 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -26,7 +26,10 @@ import { RefreshTokenSuccessAction, RegistrationAction, RegistrationErrorAction, - RegistrationSuccessAction + RegistrationSuccessAction, + RetrieveAuthenticatedEpersonAction, + RetrieveAuthenticatedEpersonErrorAction, + RetrieveAuthenticatedEpersonSuccessAction } from './auth.actions'; import { EPerson } from '../eperson/models/eperson.model'; import { AuthStatus } from './models/auth-status.model'; @@ -66,11 +69,17 @@ export class AuthEffects { ofType(AuthActionTypes.AUTHENTICATED), switchMap((action: AuthenticatedAction) => { 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))),); }) ); + @Effect() + public authenticatedSuccess$: Observable = 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" @Effect({ dispatch: false }) public authenticatedError$: Observable = this.actions$.pipe( @@ -78,6 +87,16 @@ export class AuthEffects { tap((action: LogOutSuccessAction) => this.authService.removeToken()) ); + @Effect() + public retrieveAuthenticatedEperson$: Observable = 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() public checkToken$: Observable = this.actions$.pipe(ofType(AuthActionTypes.CHECK_AUTHENTICATION_TOKEN), switchMap(() => { diff --git a/src/app/core/auth/auth.reducer.spec.ts b/src/app/core/auth/auth.reducer.spec.ts index ca2ba00036..f299696007 100644 --- a/src/app/core/auth/auth.reducer.spec.ts +++ b/src/app/core/auth/auth.reducer.spec.ts @@ -17,7 +17,7 @@ import { RefreshTokenAction, RefreshTokenErrorAction, RefreshTokenSuccessAction, - ResetAuthenticationMessagesAction, + ResetAuthenticationMessagesAction, RetrieveAuthenticatedEpersonErrorAction, RetrieveAuthenticatedEpersonSuccessAction, SetRedirectUrlAction } from './auth.actions'; import { AuthTokenInfo } from './models/auth-token-info.model'; @@ -107,16 +107,15 @@ describe('authReducer', () => { loading: true, info: undefined }; - const action = new AuthenticatedSuccessAction(true, mockTokenInfo, EPersonMock); + const action = new AuthenticatedSuccessAction(true, mockTokenInfo, EPersonMock._links.self.href); const newState = authReducer(initialState, action); state = { authenticated: true, authToken: mockTokenInfo, - loaded: true, + loaded: false, error: undefined, - loading: false, - info: undefined, - user: EPersonMock + loading: true, + info: undefined }; expect(newState).toEqual(state); }); @@ -242,6 +241,50 @@ describe('authReducer', () => { 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', () => { initialState = { authenticated: true, diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index 98827d842e..7d5e50c432 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -8,7 +8,7 @@ import { LogOutErrorAction, RedirectWhenAuthenticationIsRequiredAction, RedirectWhenTokenExpiredAction, - RefreshTokenSuccessAction, + RefreshTokenSuccessAction, RetrieveAuthenticatedEpersonSuccessAction, SetRedirectUrlAction } from './auth.actions'; // import models @@ -80,6 +80,7 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut }); case AuthActionTypes.AUTHENTICATED_ERROR: + case AuthActionTypes.RETRIEVE_AUTHENTICATED_EPERSON_ERROR: return Object.assign({}, state, { authenticated: false, authToken: undefined, @@ -91,12 +92,16 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut case AuthActionTypes.AUTHENTICATED_SUCCESS: return Object.assign({}, state, { 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, error: undefined, loading: false, info: undefined, - user: (action as AuthenticatedSuccessAction).payload.user + user: (action as RetrieveAuthenticatedEpersonSuccessAction).payload }); case AuthActionTypes.AUTHENTICATE_ERROR: diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index 0928afcb19..31649abe32 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -5,14 +5,12 @@ import { ActivatedRoute, Router } from '@angular/router'; import { Store, StoreModule } from '@ngrx/store'; import { REQUEST } from '@nguniversal/express-engine/tokens'; import { of as observableOf } from 'rxjs'; -import { LinkService } from '../cache/builders/link.service'; import { authReducer, AuthState } from './auth.reducer'; import { NativeWindowRef, NativeWindowService } from '../services/window.service'; import { AuthService } from './auth.service'; import { RouterStub } from '../../shared/testing/router-stub'; import { ActivatedRouteStub } from '../../shared/testing/active-router-stub'; - import { CookieService } from '../services/cookie.service'; import { AuthRequestServiceStub } from '../../shared/testing/auth-request-service-stub'; import { AuthRequestService } from './auth-request.service'; @@ -23,12 +21,21 @@ import { EPersonMock } from '../../shared/testing/eperson-mock'; import { AppState } from '../../app.reducer'; import { ClientCookieService } from '../services/client-cookie.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service'; import { routeServiceStub } from '../../shared/testing/route-service-stub'; 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'; describe('AuthService test', () => { + const mockEpersonDataService: any = { + findByHref(href: string): Observable> { + return createSuccessfulRemoteDataObject$(EPersonMock); + } + }; + let mockStore: Store; let authService: AuthService; let routeServiceMock: RouteService; @@ -62,7 +69,7 @@ describe('AuthService test', () => { linkService = { resolveLinks: {} }; - spyOn(linkService, 'resolveLinks').and.returnValue({authenticated: true, eperson: observableOf({payload: {}})}); + spyOn(linkService, 'resolveLinks').and.returnValue({ authenticated: true, eperson: observableOf({ payload: {} }) }); } @@ -83,7 +90,7 @@ describe('AuthService test', () => { { provide: RouteService, useValue: routeServiceStub }, { provide: ActivatedRoute, useValue: routeStub }, { provide: Store, useValue: mockStore }, - { provide: LinkService, useValue: linkService }, + { provide: EPersonDataService, useValue: mockEpersonDataService }, CookieService, AuthService ], @@ -101,8 +108,14 @@ describe('AuthService test', () => { expect(authService.authenticate.bind(null, 'user', 'passwordwrong')).toThrow(); }); - it('should return the authenticated user object when user token is valid', () => { - authService.authenticatedUser(new AuthTokenInfo('test_token')).subscribe((user: EPerson) => { + it('should return the authenticated user href when user token is valid', () => { + 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(); }); }); @@ -159,7 +172,7 @@ describe('AuthService test', () => { (state as any).core = Object.create({}); (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', () => { @@ -221,7 +234,7 @@ describe('AuthService test', () => { (state as any).core = Object.create({}); (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; routeServiceMock = TestBed.get(RouteService); routerStub = TestBed.get(Router); @@ -250,7 +263,7 @@ describe('AuthService test', () => { expect(storage.remove).toHaveBeenCalled(); }); - it ('should set redirect url to previous page', () => { + it('should set redirect url to previous page', () => { spyOn(routeServiceMock, 'getHistory').and.callThrough(); spyOn(routerStub, 'navigateByUrl'); authService.redirectAfterLoginSuccess(true); @@ -258,7 +271,7 @@ describe('AuthService test', () => { expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/collection/123'); }); - it ('should set redirect url to current page', () => { + it('should set redirect url to current page', () => { spyOn(routeServiceMock, 'getHistory').and.callThrough(); spyOn(routerStub, 'navigateByUrl'); authService.redirectAfterLoginSuccess(false); @@ -266,7 +279,7 @@ describe('AuthService test', () => { expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/home'); }); - it ('should redirect to / and not to /login', () => { + it('should redirect to / and not to /login', () => { spyOn(routeServiceMock, 'getHistory').and.returnValue(observableOf(['/login', '/login'])); spyOn(routerStub, 'navigateByUrl'); authService.redirectAfterLoginSuccess(true); @@ -274,7 +287,7 @@ describe('AuthService test', () => { expect(routerStub.navigateByUrl).toHaveBeenCalledWith('/'); }); - it ('should redirect to / when no redirect url is found', () => { + it('should redirect to / when no redirect url is found', () => { spyOn(routeServiceMock, 'getHistory').and.returnValue(observableOf([''])); spyOn(routerStub, 'navigateByUrl'); authService.redirectAfterLoginSuccess(true); diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 1da9f63b27..fd5c98f789 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -4,12 +4,10 @@ import { HttpHeaders } from '@angular/common/http'; import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; 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 { select, Store } from '@ngrx/store'; 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 { AuthRequestService } from './auth-request.service'; @@ -24,6 +22,8 @@ import { ResetAuthenticationMessagesAction, SetRedirectUrlAction } from './auth. import { NativeWindowRef, NativeWindowService } from '../services/window.service'; import { Base64EncodeUrl } from '../../shared/utils/encode-decode.util'; import { RouteService } from '../services/route.service'; +import { EPersonDataService } from '../eperson/eperson-data.service'; +import { getFirstSucceededRemoteDataPayload } from '../shared/operators'; export const LOGIN_ROUTE = '/login'; export const LOGOUT_ROUTE = '/logout'; @@ -44,13 +44,13 @@ export class AuthService { constructor(@Inject(REQUEST) protected req: any, @Inject(NativeWindowService) protected _window: NativeWindowRef, - protected authRequestService: AuthRequestService, @Optional() @Inject(RESPONSE) private response: any, + protected authRequestService: AuthRequestService, + protected epersonService: EPersonDataService, protected router: Router, protected routeService: RouteService, protected storage: CookieService, - protected store: Store, - protected linkService: LinkService + protected store: Store ) { this.store.pipe( select(isAuthenticated), @@ -123,10 +123,10 @@ export class AuthService { } /** - * Returns the authenticated user - * @returns {User} + * Returns the href link to authenticated user + * @returns {string} */ - public authenticatedUser(token: AuthTokenInfo): Observable { + public authenticatedUser(token: AuthTokenInfo): Observable { // Determine if the user has an existing auth session on the server const options: HttpOptions = Object.create({}); let headers = new HttpHeaders(); @@ -134,16 +134,25 @@ export class AuthService { headers = headers.append('Authorization', `Bearer ${token.accessToken}`); options.headers = headers; return this.authRequestService.getRequest('status', options).pipe( - map((status) => this.linkService.resolveLinks(status, followLink('eperson'))), - switchMap((status: AuthStatus) => { + map((status: AuthStatus) => { if (status.authenticated) { - return status.eperson.pipe(map((eperson) => eperson.payload)); + return status._links.eperson.href; } else { throw(new Error('Not authenticated')); } })) } + /** + * Returns the authenticated user + * @returns {User} + */ + public retrieveAuthenticatedUserByHref(userHref: string): Observable { + return this.epersonService.findByHref(userHref).pipe( + getFirstSucceededRemoteDataPayload() + ) + } + /** * Checks if token is present into browser storage and is valid. (NB Check is done only on SSR) */ diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts index eea2d83867..c8cba0206b 100644 --- a/src/app/core/auth/server-auth.service.ts +++ b/src/app/core/auth/server-auth.service.ts @@ -2,11 +2,9 @@ import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; 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 { followLink } from '../../shared/utils/follow-link-config.model'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; -import { EPerson } from '../eperson/models/eperson.model'; import { CheckAuthenticationTokenAction } from './auth.actions'; import { AuthService } from './auth.service'; import { AuthStatus } from './models/auth-status.model'; @@ -22,7 +20,7 @@ export class ServerAuthService extends AuthService { * Returns the authenticated user * @returns {User} */ - public authenticatedUser(token: AuthTokenInfo): Observable { + public authenticatedUser(token: AuthTokenInfo): Observable { // Determine if the user has an existing auth session on the server const options: HttpOptions = Object.create({}); let headers = new HttpHeaders(); @@ -35,10 +33,9 @@ export class ServerAuthService extends AuthService { options.headers = headers; return this.authRequestService.getRequest('status', options).pipe( - map((status) => this.linkService.resolveLinks(status, followLink('eperson'))), - switchMap((status: AuthStatus) => { + map((status: AuthStatus) => { if (status.authenticated) { - return status.eperson.pipe(map((eperson) => eperson.payload)); + return status._links.eperson.href; } else { throw(new Error('Not authenticated')); } diff --git a/src/app/core/breadcrumbs/breadcrumbs.service.ts b/src/app/core/breadcrumbs/breadcrumbs.service.ts new file mode 100644 index 0000000000..f274485d5d --- /dev/null +++ b/src/app/core/breadcrumbs/breadcrumbs.service.ts @@ -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 { + + /** + * 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; +} diff --git a/src/app/core/breadcrumbs/collection-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/collection-breadcrumb.resolver.ts new file mode 100644 index 0000000000..d9df7cd767 --- /dev/null +++ b/src/app/core/breadcrumbs/collection-breadcrumb.resolver.ts @@ -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 { + 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> { + return [ + followLink('parentCommunity', undefined, + followLink('parentCommunity') + ) + ]; + } +} diff --git a/src/app/core/breadcrumbs/community-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/community-breadcrumb.resolver.ts new file mode 100644 index 0000000000..d1f21455f2 --- /dev/null +++ b/src/app/core/breadcrumbs/community-breadcrumb.resolver.ts @@ -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 { + 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> { + return [ + followLink('parentCommunity') + ]; + } +} diff --git a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.spec.ts b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.spec.ts new file mode 100644 index 0000000000..2a0005f548 --- /dev/null +++ b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.spec.ts @@ -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; + 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}) + }); + }); +}); diff --git a/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts new file mode 100644 index 0000000000..80e68a16f5 --- /dev/null +++ b/src/app/core/breadcrumbs/dso-breadcrumb.resolver.ts @@ -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 implements Resolve> { + constructor(protected breadcrumbService: DSOBreadcrumbsService, protected dataService: DataService) { + } + + /** + * 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> { + 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>; +} diff --git a/src/app/core/breadcrumbs/dso-breadcrumbs.service.spec.ts b/src/app/core/breadcrumbs/dso-breadcrumbs.service.spec.ts new file mode 100644 index 0000000000..101545cb14 --- /dev/null +++ b/src/app/core/breadcrumbs/dso-breadcrumbs.service.spec.ts @@ -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 + } +}); diff --git a/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts b/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts new file mode 100644 index 0000000000..3cb73be876 --- /dev/null +++ b/src/app/core/breadcrumbs/dso-breadcrumbs.service.ts @@ -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 { + 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 { + 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) => parentRD.hasSucceeded || parentRD.statusCode === 204), + switchMap((parentRD: RemoteData) => { + if (hasValue(parentRD.payload)) { + const parent = parentRD.payload; + return this.getBreadcrumbs(parent, getDSOPath(parent)) + } + return observableOf([]); + + }), + map((breadcrumbs: Breadcrumb[]) => [...breadcrumbs, crumb]) + ); + } +} diff --git a/src/app/core/breadcrumbs/dso-name.service.spec.ts b/src/app/core/breadcrumbs/dso-name.service.spec.ts new file mode 100644 index 0000000000..aa06116ed5 --- /dev/null +++ b/src/app/core/breadcrumbs/dso-name.service.spec.ts @@ -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> { + return ['Person', Item, DSpaceObject]; + } + }); + + mockOrgUnitName = 'Molecular Spectroscopy'; + mockOrgUnit = Object.assign(new DSpaceObject(), { + firstMetadataValue(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): string { + return mockOrgUnitName + }, + getRenderTypes(): Array> { + return ['OrgUnit', Item, DSpaceObject]; + } + }); + + mockDSOName = 'Lorem Ipsum'; + mockDSO = Object.assign(new DSpaceObject(), { + firstMetadataValue(keyOrKeys: string | string[], valueFilter?: MetadataValueFilter): string { + return mockDSOName + }, + getRenderTypes(): Array> { + 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'); + }); + }); +}); diff --git a/src/app/core/breadcrumbs/dso-name.service.ts b/src/app/core/breadcrumbs/dso-name.service.ts new file mode 100644 index 0000000000..161c4f7254 --- /dev/null +++ b/src/app/core/breadcrumbs/dso-name.service.ts @@ -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); + } + } + +} diff --git a/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.spec.ts b/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.spec.ts new file mode 100644 index 0000000000..d34d6d8a9b --- /dev/null +++ b/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.spec.ts @@ -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(); + }); + }); +}); diff --git a/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.ts new file mode 100644 index 0000000000..de7d061a3f --- /dev/null +++ b/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.ts @@ -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> { + 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 { + 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 }; + } +} diff --git a/src/app/core/breadcrumbs/i18n-breadcrumbs.service.spec.ts b/src/app/core/breadcrumbs/i18n-breadcrumbs.service.spec.ts new file mode 100644 index 0000000000..274389db3b --- /dev/null +++ b/src/app/core/breadcrumbs/i18n-breadcrumbs.service.spec.ts @@ -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)] }); + }) + }); +}); diff --git a/src/app/core/breadcrumbs/i18n-breadcrumbs.service.ts b/src/app/core/breadcrumbs/i18n-breadcrumbs.service.ts new file mode 100644 index 0000000000..e07d9ed541 --- /dev/null +++ b/src/app/core/breadcrumbs/i18n-breadcrumbs.service.ts @@ -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 { + + /** + * 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 { + return observableOf([new Breadcrumb(key + BREADCRUMB_MESSAGE_POSTFIX, url)]); + } +} diff --git a/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts new file mode 100644 index 0000000000..8390c0e001 --- /dev/null +++ b/src/app/core/breadcrumbs/item-breadcrumb.resolver.ts @@ -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 { + 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> { + return [ + followLink('owningCollection', undefined, + followLink('parentCommunity', undefined, + followLink('parentCommunity')) + ), + followLink('bundles'), + followLink('relationships') + ]; + } +} diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts index 94c660d672..df895e11a2 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -96,13 +96,14 @@ export class RemoteDataBuildService { const responsePending = hasValue(reqEntry) && hasValue(reqEntry.responsePending) ? reqEntry.responsePending : false; let isSuccessful: boolean; let error: RemoteDataError; - if (hasValue(reqEntry) && hasValue(reqEntry.response)) { - isSuccessful = reqEntry.response.isSuccessful; - const errorMessage = isSuccessful === false ? (reqEntry.response as ErrorResponse).errorMessage : undefined; + const response = reqEntry ? reqEntry.response : undefined; + if (hasValue(response)) { + isSuccessful = response.isSuccessful; + const errorMessage = isSuccessful === false ? (response as ErrorResponse).errorMessage : undefined; if (hasValue(errorMessage)) { error = new RemoteDataError( - (reqEntry.response as ErrorResponse).statusCode, - (reqEntry.response as ErrorResponse).statusText, + response.statusCode, + response.statusText, errorMessage ); } @@ -112,7 +113,9 @@ export class RemoteDataBuildService { responsePending, isSuccessful, error, - payload + payload, + hasValue(response) ? response.statusCode : undefined + ); }) ); diff --git a/src/app/core/data/bitstream-format-data.service.spec.ts b/src/app/core/data/bitstream-format-data.service.spec.ts index daf3dea87c..7954416010 100644 --- a/src/app/core/data/bitstream-format-data.service.spec.ts +++ b/src/app/core/data/bitstream-format-data.service.spec.ts @@ -282,7 +282,7 @@ describe('BitstreamFormatDataService', () => { format.id = 'format-id'; const expected = cold('(b|)', {b: true}); - const result = service.delete(format); + const result = service.delete(format.id); expect(result).toBeObservable(expected); }); diff --git a/src/app/core/data/bitstream-format-data.service.ts b/src/app/core/data/bitstream-format-data.service.ts index 5c7029a09f..e8cf030a52 100644 --- a/src/app/core/data/bitstream-format-data.service.ts +++ b/src/app/core/data/bitstream-format-data.service.ts @@ -154,19 +154,19 @@ export class BitstreamFormatDataService extends DataService { /** * 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 */ - delete(format: BitstreamFormat): Observable { + delete(formatID: string): Observable { const requestId = this.requestService.generateRequestId(); 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( find((href: string) => hasValue(href)), map((href: string) => { - const request = new DeleteByIDRequest(requestId, href, format.id); + const request = new DeleteByIDRequest(requestId, href, formatID); this.requestService.configure(request); }) ).subscribe(); diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 3be1ef9768..7dcfb6bd6e 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -152,7 +152,11 @@ export abstract class DataService { /** * Returns {@link RemoteData} of all object with a list of {@link FollowLinkConfig}, to indicate which embedded * info should be added to the objects + * + * @param options Find list options object * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + * @return {Observable>>} + * Return an observable that emits object list */ findAll(options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { return this.findList(this.getFindAllHref(options), options, ...linksToFollow); @@ -162,6 +166,7 @@ export abstract class DataService { * 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 * @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 */ protected findList(href$, options: FindListOptions, ...linksToFollow: Array>) { @@ -231,6 +236,7 @@ export abstract class DataService { * Returns a list of observables of {@link RemoteData} of objects, based on an href, with a list of {@link FollowLinkConfig}, * to automatically resolve {@link HALLink}s of the object * @param href The url of object we want to retrieve + * @param findListOptions Find list options object * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ findAllByHref(href: string, findListOptions: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { @@ -259,6 +265,7 @@ export abstract class DataService { * * @param searchMethod The search method for the object * @param options The [[FindListOptions]] object + * @param linksToFollow The array of [[FollowLinkConfig]] * @return {Observable>} * Return an observable that emits response from the server */ @@ -367,16 +374,16 @@ export abstract class DataService { /** * 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 * metadata should be saved as real metadata * @return an observable that emits true when the deletion was successful, false when it failed */ - delete(dso: T, copyVirtualMetadata?: string[]): Observable { + delete(dsoID: string, copyVirtualMetadata?: string[]): Observable { const requestId = this.requestService.generateRequestId(); 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( find((href: string) => hasValue(href)), @@ -388,7 +395,7 @@ export abstract class DataService { + id ); } - const request = new DeleteByIDRequest(requestId, href, dso.uuid); + const request = new DeleteByIDRequest(requestId, href, dsoID); this.requestService.configure(request); }) ).subscribe(); diff --git a/src/app/core/data/remote-data.ts b/src/app/core/data/remote-data.ts index 3be9248907..8502c8ba1d 100644 --- a/src/app/core/data/remote-data.ts +++ b/src/app/core/data/remote-data.ts @@ -17,7 +17,8 @@ export class RemoteData { private responsePending?: boolean, private isSuccessful?: boolean, public error?: RemoteDataError, - public payload?: T + public payload?: T, + public statusCode?: number, ) { } diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index 1417005b9d..dbba9d83f6 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -10,6 +10,7 @@ import { catchError, distinctUntilKeyChanged, filter, first, map, take } from 'r import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { DSONameService } from '../breadcrumbs/dso-name.service'; import { CacheableObject } from '../cache/object-cache.reducer'; import { BitstreamDataService } from '../data/bitstream-data.service'; import { BitstreamFormatDataService } from '../data/bitstream-format-data.service'; @@ -35,6 +36,7 @@ export class MetadataService { private translate: TranslateService, private meta: Meta, private title: Title, + private dsoNameService: DSONameService, private bitstreamDataService: BitstreamDataService, private bitstreamFormatDataService: BitstreamFormatDataService, @Inject(GLOBAL_CONFIG) private envConfig: GlobalConfig @@ -154,7 +156,7 @@ export class MetadataService { * Add to the */ private setTitleTag(): void { - const value = this.getMetaTagValue('dc.title'); + const value = this.dsoNameService.getName(this.currentObject.getValue()); this.addMetaTag('title', value); this.title.setTitle(value); } diff --git a/src/app/core/shared/child-hal-resource.model.ts b/src/app/core/shared/child-hal-resource.model.ts new file mode 100644 index 0000000000..ee022942bb --- /dev/null +++ b/src/app/core/shared/child-hal-resource.model.ts @@ -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']; +} diff --git a/src/app/core/shared/collection.model.ts b/src/app/core/shared/collection.model.ts index d5c6221428..ba2f448bba 100644 --- a/src/app/core/shared/collection.model.ts +++ b/src/app/core/shared/collection.model.ts @@ -12,10 +12,13 @@ import { License } from './license.model'; import { LICENSE } from './license.resource-type'; import { ResourcePolicy } from './resource-policy.model'; 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 @inheritSerialization(DSpaceObject) -export class Collection extends DSpaceObject { +export class Collection extends DSpaceObject implements ChildHALResource { static type = COLLECTION; /** @@ -35,6 +38,7 @@ export class Collection extends DSpaceObject { itemtemplate: HALLink; defaultAccessConditions: HALLink; logo: HALLink; + parentCommunity: HALLink; self: HALLink; }; @@ -59,6 +63,13 @@ export class Collection extends DSpaceObject { @link(RESOURCE_POLICY, true) defaultAccessConditions?: Observable>>; + /** + * 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>; + /** * The introductory text of this Collection * Corresponds to the metadata field dc.description @@ -98,4 +109,8 @@ export class Collection extends DSpaceObject { get sidebarText(): string { return this.firstMetadataValue('dc.description.tableofcontents'); } + + getParentLinkKey(): keyof this['_links'] { + return 'parentCommunity'; + } } diff --git a/src/app/core/shared/community.model.ts b/src/app/core/shared/community.model.ts index 703c4b3eef..e18ec743e8 100644 --- a/src/app/core/shared/community.model.ts +++ b/src/app/core/shared/community.model.ts @@ -10,10 +10,11 @@ import { COLLECTION } from './collection.resource-type'; import { COMMUNITY } from './community.resource-type'; import { DSpaceObject } from './dspace-object.model'; import { HALLink } from './hal-link.model'; +import { ChildHALResource } from './child-hal-resource.model'; @typedObject @inheritSerialization(DSpaceObject) -export class Community extends DSpaceObject { +export class Community extends DSpaceObject implements ChildHALResource { static type = COMMUNITY; /** @@ -30,6 +31,7 @@ export class Community extends DSpaceObject { collections: HALLink; logo: HALLink; subcommunities: HALLink; + parentCommunity: HALLink; self: HALLink; }; @@ -54,6 +56,13 @@ export class Community extends DSpaceObject { @link(COMMUNITY, true) subcommunities?: Observable>>; + /** + * 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>; + /** * The introductory text of this Community * Corresponds to the metadata field dc.description @@ -85,4 +94,8 @@ export class Community extends DSpaceObject { get sidebarText(): string { return this.firstMetadataValue('dc.description.tableofcontents'); } + + getParentLinkKey(): keyof this['_links'] { + return 'parentCommunity'; + } } diff --git a/src/app/core/shared/dspace-object.model.ts b/src/app/core/shared/dspace-object.model.ts index 2e1afe9c8a..60a1160d3e 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -69,6 +69,7 @@ export class DSpaceObject extends ListableObject implements CacheableObject { /** * The name for this DSpaceObject + * @deprecated use {@link DSONameService} instead */ get name(): string { return (isUndefined(this._name)) ? this.firstMetadataValue('dc.title') : this._name; diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts index 3fd35280da..e7f0ae9e10 100644 --- a/src/app/core/shared/item.model.ts +++ b/src/app/core/shared/item.model.ts @@ -17,13 +17,14 @@ import { HALLink } from './hal-link.model'; import { Relationship } from './item-relationships/relationship.model'; import { RELATIONSHIP } from './item-relationships/relationship.resource-type'; import { ITEM } from './item.resource-type'; +import { ChildHALResource } from './child-hal-resource.model'; /** * Class representing a DSpace Item */ @typedObject @inheritSerialization(DSpaceObject) -export class Item extends DSpaceObject { +export class Item extends DSpaceObject implements ChildHALResource { static type = ITEM; /** @@ -100,4 +101,8 @@ export class Item extends DSpaceObject { } return [entityType, ...super.getRenderTypes()]; } + + getParentLinkKey(): keyof this['_links'] { + return 'owningCollection'; + } } diff --git a/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.spec.ts b/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.spec.ts index 4f899edaa8..2d28821738 100644 --- a/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.spec.ts +++ b/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.spec.ts @@ -23,7 +23,7 @@ describe('OrgUnitItemMetadataListElementComponent', () => { declarations: [OrgUnitItemMetadataListElementComponent], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(OrgUnitItemMetadataListElementComponent, { - // set: { changeDetection: ChangeDetectionStrategy.Default } + set: { changeDetection: ChangeDetectionStrategy.Default } }).compileComponents(); })); diff --git a/src/app/entity-groups/research-entities/metadata-representations/person/person-item-metadata-list-element.component.spec.ts b/src/app/entity-groups/research-entities/metadata-representations/person/person-item-metadata-list-element.component.spec.ts index 1081e45884..97087728f8 100644 --- a/src/app/entity-groups/research-entities/metadata-representations/person/person-item-metadata-list-element.component.spec.ts +++ b/src/app/entity-groups/research-entities/metadata-representations/person/person-item-metadata-list-element.component.spec.ts @@ -29,12 +29,12 @@ describe('PersonItemMetadataListElementComponent', () => { }).compileComponents(); })); - beforeEach(async(() => { + beforeEach(() => { fixture = TestBed.createComponent(PersonItemMetadataListElementComponent); comp = fixture.componentInstance; comp.metadataRepresentation = mockItemMetadataRepresentation; fixture.detectChanges(); - })); + }); it('should show the person\'s name as a link', () => { const linkText = fixture.debugElement.query(By.css('a')).nativeElement.textContent; diff --git a/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.spec.ts b/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.spec.ts index 3b39d36008..dbbeea5bc6 100644 --- a/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.spec.ts +++ b/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.spec.ts @@ -125,7 +125,7 @@ describe('DeleteComColPageComponent', () => { it('should call delete on the data service', () => { comp.onConfirm(data1); fixture.detectChanges(); - expect(dsoDataService.delete).toHaveBeenCalledWith(data1); + expect(dsoDataService.delete).toHaveBeenCalledWith(data1.id); }); }); diff --git a/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.ts b/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.ts index 57c860e04f..f5a1a84af5 100644 --- a/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.ts +++ b/src/app/shared/comcol-forms/delete-comcol-page/delete-comcol-page.component.ts @@ -43,7 +43,7 @@ export class DeleteComColPageComponent implements * 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) { - this.dsoDataService.delete(dso) + this.dsoDataService.delete(dso.id) .pipe(first()) .subscribe((success: boolean) => { if (success) { diff --git a/src/app/shared/comcol-page-browse-by/comcol-page-browse-by.component.ts b/src/app/shared/comcol-page-browse-by/comcol-page-browse-by.component.ts index 1bc83d74a5..091e02723f 100644 --- a/src/app/shared/comcol-page-browse-by/comcol-page-browse-by.component.ts +++ b/src/app/shared/comcol-page-browse-by/comcol-page-browse-by.component.ts @@ -12,7 +12,7 @@ import { filter, map, startWith, tap } from 'rxjs/operators'; import { getCollectionPageRoute } from '../../+collection-page/collection-page-routing.module'; import { getCommunityPageRoute } from '../../+community-page/community-page-routing.module'; 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 { hasValue } from '../empty.util'; @@ -76,9 +76,8 @@ export class ComcolPageBrowseByComponent implements OnInit { }, ...this.allOptions ]; } - this.currentOptionId$ = this.route.url.pipe( - filter((urlSegments: UrlSegment[]) => hasValue(urlSegments)), - map((urlSegments: UrlSegment[]) => urlSegments[urlSegments.length - 1].path) + this.currentOptionId$ = this.route.params.pipe( + map((params: Params) => params.id) ); } diff --git a/src/app/shared/form/builder/parsers/field-parser.ts b/src/app/shared/form/builder/parsers/field-parser.ts index f7bf12353c..f218d442e1 100644 --- a/src/app/shared/form/builder/parsers/field-parser.ts +++ b/src/app/shared/form/builder/parsers/field-parser.ts @@ -49,7 +49,7 @@ export abstract class FieldParser { label: this.configData.label, initialCount: this.getInitArrayIndex(), notRepeatable: !this.configData.repeatable, - required: isNotEmpty(this.configData.mandatory), + required: JSON.parse( this.configData.mandatory), groupFactory: () => { let model; if ((arrayCounter === 0)) { diff --git a/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.spec.ts b/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.spec.ts index 8950966e26..00f5422b27 100644 --- a/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.spec.ts +++ b/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.spec.ts @@ -1,5 +1,5 @@ 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 { By } from '@angular/platform-browser'; @@ -141,7 +141,7 @@ describe('WorkspaceitemActionsComponent', () => { fixture.detectChanges(); fixture.whenStable().then(() => { - expect(mockDataService.delete).toHaveBeenCalledWith(mockObject); + expect(mockDataService.delete).toHaveBeenCalledWith(mockObject.id); }); }); diff --git a/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.ts b/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.ts index 2378c8e251..27512d899e 100644 --- a/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.ts +++ b/src/app/shared/mydspace-actions/workspaceitem/workspaceitem-actions.component.ts @@ -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 { BehaviorSubject } from 'rxjs'; @@ -62,7 +62,7 @@ export class WorkspaceitemActionsComponent extends MyDSpaceActionsComponent { if (result === 'ok') { this.processingDelete$.next(true); - this.objectDataService.delete(this.object) + this.objectDataService.delete(this.object.id) .subscribe((response: boolean) => { this.processingDelete$.next(false); this.handleActionResponse(response); diff --git a/src/app/shared/testing/auth-request-service-stub.ts b/src/app/shared/testing/auth-request-service-stub.ts index 82ce682a9b..b32b5395ba 100644 --- a/src/app/shared/testing/auth-request-service-stub.ts +++ b/src/app/shared/testing/auth-request-service-stub.ts @@ -5,8 +5,6 @@ import { AuthTokenInfo } from '../../core/auth/models/auth-token-info.model'; import { EPerson } from '../../core/eperson/models/eperson.model'; import { isNotEmpty } from '../empty.util'; import { EPersonMock } from './eperson-mock'; -import { RemoteData } from '../../core/data/remote-data'; -import { createSuccessfulRemoteDataObject$ } from './utils'; export class AuthRequestServiceStub { protected mockUser: EPerson = EPersonMock; @@ -28,7 +26,14 @@ export class AuthRequestServiceStub { if (this.validateToken(token)) { authStatusStub.authenticated = true; authStatusStub.token = this.mockTokenInfo; - authStatusStub.eperson = createSuccessfulRemoteDataObject$(this.mockUser); + authStatusStub._links = { + self: { + href: 'dspace.org/api/status', + }, + eperson: { + href: this.mockUser._links.self.href + } + }; } else { authStatusStub.authenticated = false; } @@ -47,7 +52,14 @@ export class AuthRequestServiceStub { if (this.validateToken(token)) { authStatusStub.authenticated = true; authStatusStub.token = this.mockTokenInfo; - authStatusStub.eperson = createSuccessfulRemoteDataObject$(this.mockUser); + authStatusStub._links = { + self: { + href: 'dspace.org/api/status', + }, + eperson: { + href: this.mockUser._links.self.href + } + }; } else { authStatusStub.authenticated = false; } diff --git a/src/app/shared/testing/auth-service-stub.ts b/src/app/shared/testing/auth-service-stub.ts index a6d24d5c8b..a3c6351ccd 100644 --- a/src/app/shared/testing/auth-service-stub.ts +++ b/src/app/shared/testing/auth-service-stub.ts @@ -3,7 +3,6 @@ import { AuthStatus } from '../../core/auth/models/auth-status.model'; import { AuthTokenInfo } from '../../core/auth/models/auth-token-info.model'; import { EPersonMock } from './eperson-mock'; import { EPerson } from '../../core/eperson/models/eperson.model'; -import { RemoteData } from '../../core/data/remote-data'; import { createSuccessfulRemoteDataObject$ } from './utils'; export class AuthServiceStub { @@ -30,14 +29,18 @@ export class AuthServiceStub { } } - public authenticatedUser(token: AuthTokenInfo): Observable { + public authenticatedUser(token: AuthTokenInfo): Observable { if (token.accessToken === 'token_test') { - return observableOf(EPersonMock); + return observableOf(EPersonMock._links.self.href); } else { throw(new Error('Message Error test')); } } + public retrieveAuthenticatedUserByHref(href: string): Observable { + return observableOf(EPersonMock); + } + public buildAuthHeader(token?: AuthTokenInfo): string { return `Bearer ${token.accessToken}`; } diff --git a/src/app/submission/edit/submission-edit.component.spec.ts b/src/app/submission/edit/submission-edit.component.spec.ts index 115016d2fe..3b8695b023 100644 --- a/src/app/submission/edit/submission-edit.component.spec.ts +++ b/src/app/submission/edit/submission-edit.component.spec.ts @@ -74,7 +74,7 @@ describe('SubmissionEditComponent Component', () => { expect(comp.submissionId).toBe(submissionId); expect(comp.collectionId).toBe(submissionObject.collection.id); - expect(comp.selfUrl).toBe(submissionObject.self); + expect(comp.selfUrl).toBe(submissionObject._links.self.href); expect(comp.sections).toBe(submissionObject.sections); expect(comp.submissionDefinition).toBe(submissionObject.submissionDefinition); diff --git a/src/app/submission/edit/submission-edit.component.ts b/src/app/submission/edit/submission-edit.component.ts index 60c8b9a7a3..908f473136 100644 --- a/src/app/submission/edit/submission-edit.component.ts +++ b/src/app/submission/edit/submission-edit.component.ts @@ -94,7 +94,7 @@ export class SubmissionEditComponent implements OnDestroy, OnInit { } else { this.submissionId = submissionObjectRD.payload.id.toString(); this.collectionId = (submissionObjectRD.payload.collection as Collection).id; - this.selfUrl = submissionObjectRD.payload.self; + this.selfUrl = submissionObjectRD.payload._links.self.href; this.sections = submissionObjectRD.payload.sections; this.submissionDefinition = (submissionObjectRD.payload.submissionDefinition as SubmissionDefinitionsModel); this.changeDetectorRef.detectChanges(); diff --git a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts index 19ee5d6d3d..217754b42e 100644 --- a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts +++ b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.component.ts @@ -10,7 +10,9 @@ import { DynamicFormControlEvent, DynamicFormControlModel, DynamicFormGroupModel, - DynamicSelectModel + DynamicSelectModel, + MATCH_ENABLED, + OR_OPERATOR } from '@ng-dynamic-forms/core'; import { WorkspaceitemSectionUploadFileObject } from '../../../../../core/submission/models/workspaceitem-section-upload-file.model'; @@ -206,9 +208,9 @@ export class SubmissionSectionUploadFileEditComponent implements OnChanges { hasGroups.push({ id: 'name', value: condition.name }); } }); - const confStart = { relation: [{ action: 'ENABLE', connective: 'OR', when: hasStart }] }; - const confEnd = { relation: [{ action: 'ENABLE', connective: 'OR', when: hasEnd }] }; - const confGroup = { relation: [{ action: 'ENABLE', connective: 'OR', when: hasGroups }] }; + const confStart = { relations: [{ match: MATCH_ENABLED, operator: OR_OPERATOR, when: hasStart }] }; + const confEnd = { relations: [{ match: MATCH_ENABLED, operator: OR_OPERATOR, when: hasEnd }] }; + const confGroup = { relations: [{ match: MATCH_ENABLED, operator: OR_OPERATOR, when: hasGroups }] }; accessConditionsArrayConfig.groupFactory = () => { const type = new DynamicSelectModel(accessConditionTypeModelConfig, BITSTREAM_FORM_ACCESS_CONDITION_TYPE_LAYOUT); diff --git a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.model.ts b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.model.ts index bc994aac52..dd2ac7a2a7 100644 --- a/src/app/submission/sections/upload/file/edit/section-upload-file-edit.model.ts +++ b/src/app/submission/sections/upload/file/edit/section-upload-file-edit.model.ts @@ -1,8 +1,11 @@ import { DynamicDatePickerModelConfig, DynamicFormArrayModelConfig, + DynamicFormControlLayout, + DynamicFormGroupModelConfig, DynamicSelectModelConfig, - DynamicFormGroupModelConfig, DynamicFormControlLayout, + MATCH_ENABLED, + OR_OPERATOR, } from '@ng-dynamic-forms/core'; export const BITSTREAM_METADATA_FORM_GROUP_CONFIG: DynamicFormGroupModelConfig = { @@ -15,7 +18,7 @@ export const BITSTREAM_METADATA_FORM_GROUP_LAYOUT: DynamicFormControlLayout = { label: 'col-form-label' }, grid: { - label: 'col-sm-3' + label: 'col-sm-3' } }; @@ -52,8 +55,8 @@ export const BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_CONFIG: DynamicDatePicke toggleIcon: 'far fa-calendar-alt', relations: [ { - match: 'ENABLE', - operator: 'OR', + match: MATCH_ENABLED, + operator: OR_OPERATOR, when: [] } ], @@ -83,8 +86,8 @@ export const BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_CONFIG: DynamicDatePickerM toggleIcon: 'far fa-calendar-alt', relations: [ { - match: 'ENABLE', - operator: 'OR', + match: MATCH_ENABLED, + operator: OR_OPERATOR, when: [] } ], @@ -112,8 +115,8 @@ export const BITSTREAM_FORM_ACCESS_CONDITION_GROUPS_CONFIG: DynamicSelectModelCo options: [], relations: [ { - match: 'ENABLE', - operator: 'OR', + match: MATCH_ENABLED, + operator: OR_OPERATOR, when: [] } ], diff --git a/src/app/submission/sections/upload/section-upload.component.ts b/src/app/submission/sections/upload/section-upload.component.ts index 86da00c816..f8f096d4bd 100644 --- a/src/app/submission/sections/upload/section-upload.component.ts +++ b/src/app/submission/sections/upload/section-upload.component.ts @@ -164,7 +164,8 @@ export class SubmissionSectionUploadComponent extends SectionModelComponent { flatMap((submissionObject: SubmissionObjectEntry) => this.collectionDataService.findById(submissionObject.collection)), filter((rd: RemoteData) => isNotUndefined((rd.payload))), tap((collectionRemoteData: RemoteData) => this.collectionName = collectionRemoteData.payload.name), - flatMap((collectionRemoteData: RemoteData) => { + // TODO review this part when https://github.com/DSpace/dspace-angular/issues/575 is resolved +/* flatMap((collectionRemoteData: RemoteData) => { return this.resourcePolicyService.findByHref( (collectionRemoteData.payload as any)._links.defaultAccessConditions.href ); @@ -176,7 +177,7 @@ export class SubmissionSectionUploadComponent extends SectionModelComponent { this.collectionDefaultAccessConditions = Array.isArray(defaultAccessConditionsRemoteData.payload) ? defaultAccessConditionsRemoteData.payload : [defaultAccessConditionsRemoteData.payload]; } - }), + }),*/ flatMap(() => config$), flatMap((config: SubmissionUploadsModel) => { this.required$.next(config.required); diff --git a/src/app/submission/submit/submission-submit.component.spec.ts b/src/app/submission/submit/submission-submit.component.spec.ts index ca3316669f..809a4dd627 100644 --- a/src/app/submission/submit/submission-submit.component.spec.ts +++ b/src/app/submission/submit/submission-submit.component.spec.ts @@ -68,7 +68,7 @@ describe('SubmissionSubmitComponent Component', () => { expect(comp.submissionId.toString()).toEqual(submissionId); expect(comp.collectionId).toBe(submissionObject.collection.id); - expect(comp.selfUrl).toBe(submissionObject.self); + expect(comp.selfUrl).toBe(submissionObject._links.self.href); expect(comp.submissionDefinition).toBe(submissionObject.submissionDefinition); })); diff --git a/src/app/submission/submit/submission-submit.component.ts b/src/app/submission/submit/submission-submit.component.ts index 0aa0380a25..d3d3ca4e66 100644 --- a/src/app/submission/submit/submission-submit.component.ts +++ b/src/app/submission/submit/submission-submit.component.ts @@ -95,7 +95,7 @@ export class SubmissionSubmitComponent implements OnDestroy, OnInit { this.router.navigate(['/mydspace']); } else { this.collectionId = (submissionObject.collection as Collection).id; - this.selfUrl = submissionObject.self; + this.selfUrl = submissionObject._links.self.href; this.submissionDefinition = (submissionObject.submissionDefinition as SubmissionDefinitionsModel); this.submissionId = submissionObject.id; this.changeDetectorRef.detectChanges();