+
+ +
+
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/app.reducer.ts b/src/app/app.reducer.ts index 837fb9befd..a40005814a 100644 --- a/src/app/app.reducer.ts +++ b/src/app/app.reducer.ts @@ -1,30 +1,34 @@ -import { ActionReducerMap, createSelector, MemoizedSelector } from '@ngrx/store'; import * as fromRouter from '@ngrx/router-store'; - -import { hostWindowReducer, HostWindowState } from './shared/search/host-window.reducer'; -import { CommunityListReducer, CommunityListState } from './community-list-page/community-list.reducer'; -import { formReducer, FormState } from './shared/form/form.reducer'; -import { sidebarReducer, SidebarState } from './shared/sidebar/sidebar.reducer'; -import { sidebarFilterReducer, SidebarFiltersState } from './shared/sidebar/filter/sidebar-filter.reducer'; -import { filterReducer, SearchFiltersState } from './shared/search/search-filters/search-filter/search-filter.reducer'; -import { notificationsReducer, NotificationsState } from './shared/notifications/notifications.reducers'; -import { truncatableReducer, TruncatablesState } from './shared/truncatable/truncatable.reducer'; +import { ActionReducerMap, createSelector, MemoizedSelector } from '@ngrx/store'; +import { + ePeopleRegistryReducer, + EPeopleRegistryState +} from './+admin/admin-access-control/epeople-registry/epeople-registry.reducers'; import { metadataRegistryReducer, MetadataRegistryState } from './+admin/admin-registries/metadata-registry/metadata-registry.reducers'; +import { CommunityListReducer, CommunityListState } from './community-list-page/community-list.reducer'; import { hasValue } from './shared/empty.util'; -import { cssVariablesReducer, CSSVariablesState } from './shared/sass-helper/sass-helper.reducer'; +import { + NameVariantListsState, + nameVariantReducer +} from './shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer'; +import { formReducer, FormState } from './shared/form/form.reducer'; import { menusReducer, MenusState } from './shared/menu/menu.reducer'; +import { notificationsReducer, NotificationsState } from './shared/notifications/notifications.reducers'; import { selectableListReducer, SelectableListsState } from './shared/object-list/selectable-list/selectable-list.reducer'; import { ObjectSelectionListState, objectSelectionReducer } from './shared/object-select/object-select.reducer'; -import { - NameVariantListsState, - nameVariantReducer -} from './shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer'; +import { cssVariablesReducer, CSSVariablesState } from './shared/sass-helper/sass-helper.reducer'; + +import { hostWindowReducer, HostWindowState } from './shared/search/host-window.reducer'; +import { filterReducer, SearchFiltersState } from './shared/search/search-filters/search-filter/search-filter.reducer'; +import { sidebarFilterReducer, SidebarFiltersState } from './shared/sidebar/filter/sidebar-filter.reducer'; +import { sidebarReducer, SidebarState } from './shared/sidebar/sidebar.reducer'; +import { truncatableReducer, TruncatablesState } from './shared/truncatable/truncatable.reducer'; export interface AppState { router: fromRouter.RouterReducerState; @@ -42,6 +46,7 @@ export interface AppState { selectableLists: SelectableListsState; relationshipLists: NameVariantListsState; communityList: CommunityListState; + epeopleRegistry: EPeopleRegistryState; } export const appReducers: ActionReducerMap = { @@ -60,6 +65,7 @@ export const appReducers: ActionReducerMap = { selectableLists: selectableListReducer, relationshipLists: nameVariantReducer, communityList: CommunityListReducer, + epeopleRegistry: ePeopleRegistryReducer, }; export const routerStateSelector = (state: AppState) => state.router; 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..e5c9210769 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'; @@ -11,6 +10,7 @@ import { AuthGetRequest, AuthPostRequest, GetRequest, PostRequest, RestRequest } import { AuthStatusResponse, ErrorResponse } from '../cache/response.models'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { getResponseFromEntry } from '../shared/operators'; +import { HttpClient } from '@angular/common/http'; @Injectable() export class AuthRequestService { @@ -19,7 +19,8 @@ export class AuthRequestService { constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, protected halService: HALEndpointService, - protected requestService: RequestService) { + protected requestService: RequestService, + private http: HttpClient) { } protected fetchRequest(request: RestRequest): Observable { @@ -39,7 +40,7 @@ export class AuthRequestService { return isNotEmpty(method) ? `${endpoint}/${method}` : `${endpoint}`; } - public postToEndpoint(method: string, body: any, options?: HttpOptions): Observable { + public postToEndpoint(method: string, body?: any, options?: HttpOptions): Observable { return this.halService.getEndpoint(this.linkName).pipe( filter((href: string) => isNotEmpty(href)), map((endpointURL) => this.getEndpointByMethod(endpointURL, method)), @@ -68,4 +69,5 @@ export class AuthRequestService { mergeMap((request: GetRequest) => this.fetchRequest(request)), distinctUntilChanged()); } + } diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts index d0969d38d4..2c2224e878 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -1,12 +1,12 @@ // 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'; +import { AuthMethod } from './models/auth.method'; +import { AuthStatus } from './models/auth-status.model'; export const AuthActionTypes = { AUTHENTICATE: type('dspace/auth/AUTHENTICATE'), @@ -16,12 +16,16 @@ export const AuthActionTypes = { AUTHENTICATED_ERROR: type('dspace/auth/AUTHENTICATED_ERROR'), AUTHENTICATED_SUCCESS: type('dspace/auth/AUTHENTICATED_SUCCESS'), CHECK_AUTHENTICATION_TOKEN: type('dspace/auth/CHECK_AUTHENTICATION_TOKEN'), - CHECK_AUTHENTICATION_TOKEN_ERROR: type('dspace/auth/CHECK_AUTHENTICATION_TOKEN_ERROR'), + CHECK_AUTHENTICATION_TOKEN_COOKIE: type('dspace/auth/CHECK_AUTHENTICATION_TOKEN_COOKIE'), + RETRIEVE_AUTH_METHODS: type('dspace/auth/RETRIEVE_AUTH_METHODS'), + RETRIEVE_AUTH_METHODS_SUCCESS: type('dspace/auth/RETRIEVE_AUTH_METHODS_SUCCESS'), + RETRIEVE_AUTH_METHODS_ERROR: type('dspace/auth/RETRIEVE_AUTH_METHODS_ERROR'), REDIRECT_TOKEN_EXPIRED: type('dspace/auth/REDIRECT_TOKEN_EXPIRED'), REDIRECT_AUTHENTICATION_REQUIRED: type('dspace/auth/REDIRECT_AUTHENTICATION_REQUIRED'), REFRESH_TOKEN: type('dspace/auth/REFRESH_TOKEN'), REFRESH_TOKEN_SUCCESS: type('dspace/auth/REFRESH_TOKEN_SUCCESS'), REFRESH_TOKEN_ERROR: type('dspace/auth/REFRESH_TOKEN_ERROR'), + RETRIEVE_TOKEN: type('dspace/auth/RETRIEVE_TOKEN'), ADD_MESSAGE: type('dspace/auth/ADD_MESSAGE'), RESET_MESSAGES: type('dspace/auth/RESET_MESSAGES'), LOG_OUT: type('dspace/auth/LOG_OUT'), @@ -31,6 +35,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 +83,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 }; } } @@ -94,7 +101,7 @@ export class AuthenticatedErrorAction implements Action { payload: Error; constructor(payload: Error) { - this.payload = payload ; + this.payload = payload; } } @@ -108,7 +115,7 @@ export class AuthenticationErrorAction implements Action { payload: Error; constructor(payload: Error) { - this.payload = payload ; + this.payload = payload; } } @@ -137,11 +144,11 @@ export class CheckAuthenticationTokenAction implements Action { /** * Check Authentication Token Error. - * @class CheckAuthenticationTokenErrorAction + * @class CheckAuthenticationTokenCookieAction * @implements {Action} */ -export class CheckAuthenticationTokenErrorAction implements Action { - public type: string = AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_ERROR; +export class CheckAuthenticationTokenCookieAction implements Action { + public type: string = AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE; } /** @@ -151,7 +158,9 @@ export class CheckAuthenticationTokenErrorAction implements Action { */ export class LogOutAction implements Action { public type: string = AuthActionTypes.LOG_OUT; - constructor(public payload?: any) {} + + constructor(public payload?: any) { + } } /** @@ -164,7 +173,7 @@ export class LogOutErrorAction implements Action { payload: Error; constructor(payload: Error) { - this.payload = payload ; + this.payload = payload; } } @@ -175,7 +184,9 @@ export class LogOutErrorAction implements Action { */ export class LogOutSuccessAction implements Action { public type: string = AuthActionTypes.LOG_OUT_SUCCESS; - constructor(public payload?: any) {} + + constructor(public payload?: any) { + } } /** @@ -188,7 +199,7 @@ export class RedirectWhenAuthenticationIsRequiredAction implements Action { payload: string; constructor(message: string) { - this.payload = message ; + this.payload = message; } } @@ -202,7 +213,7 @@ export class RedirectWhenTokenExpiredAction implements Action { payload: string; constructor(message: string) { - this.payload = message ; + this.payload = message; } } @@ -243,6 +254,15 @@ export class RefreshTokenErrorAction implements Action { public type: string = AuthActionTypes.REFRESH_TOKEN_ERROR; } +/** + * Retrieve authentication token. + * @class RetrieveTokenAction + * @implements {Action} + */ +export class RetrieveTokenAction implements Action { + public type: string = AuthActionTypes.RETRIEVE_TOKEN; +} + /** * Sign up. * @class RegistrationAction @@ -267,7 +287,7 @@ export class RegistrationErrorAction implements Action { payload: Error; constructor(payload: Error) { - this.payload = payload ; + this.payload = payload; } } @@ -308,6 +328,45 @@ export class ResetAuthenticationMessagesAction implements Action { public type: string = AuthActionTypes.RESET_MESSAGES; } +// // Next three Actions are used by dynamic login methods +/** + * Action that triggers an effect fetching the authentication methods enabled ant the backend + * @class RetrieveAuthMethodsAction + * @implements {Action} + */ +export class RetrieveAuthMethodsAction implements Action { + public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS; + + payload: AuthStatus; + + constructor(authStatus: AuthStatus) { + this.payload = authStatus; + } +} + +/** + * Get Authentication methods enabled at the backend + * @class RetrieveAuthMethodsSuccessAction + * @implements {Action} + */ +export class RetrieveAuthMethodsSuccessAction implements Action { + public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS; + payload: AuthMethod[]; + + constructor(authMethods: AuthMethod[] ) { + this.payload = authMethods; + } +} + +/** + * Set password as default authentication method on error + * @class RetrieveAuthMethodsErrorAction + * @implements {Action} + */ +export class RetrieveAuthMethodsErrorAction implements Action { + public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR; +} + /** * Change the redirect url. * @class SetRedirectUrlAction @@ -318,10 +377,51 @@ export class SetRedirectUrlAction implements Action { payload: string; constructor(url: string) { - this.payload = url ; + this.payload = url; } } +/** + * 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 */ /** @@ -336,11 +436,23 @@ export type AuthActions | AuthenticationErrorAction | AuthenticationSuccessAction | CheckAuthenticationTokenAction - | CheckAuthenticationTokenErrorAction + | CheckAuthenticationTokenCookieAction | RedirectWhenAuthenticationIsRequiredAction | RedirectWhenTokenExpiredAction | RegistrationAction | RegistrationErrorAction | RegistrationSuccessAction | AddAuthenticationMessageAction - | ResetAuthenticationMessagesAction; + | RefreshTokenAction + | RefreshTokenErrorAction + | RefreshTokenSuccessAction + | ResetAuthenticationMessagesAction + | RetrieveAuthMethodsAction + | RetrieveAuthMethodsSuccessAction + | RetrieveAuthMethodsErrorAction + | RetrieveTokenAction + | 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..1f6fa51afd 100644 --- a/src/app/core/auth/auth.effects.spec.ts +++ b/src/app/core/auth/auth.effects.spec.ts @@ -14,17 +14,24 @@ import { AuthenticatedSuccessAction, AuthenticationErrorAction, AuthenticationSuccessAction, - CheckAuthenticationTokenErrorAction, + CheckAuthenticationTokenCookieAction, LogOutErrorAction, LogOutSuccessAction, RefreshTokenErrorAction, - RefreshTokenSuccessAction + RefreshTokenSuccessAction, + RetrieveAuthenticatedEpersonAction, + RetrieveAuthenticatedEpersonErrorAction, + RetrieveAuthenticatedEpersonSuccessAction, + RetrieveAuthMethodsAction, + RetrieveAuthMethodsErrorAction, + RetrieveAuthMethodsSuccessAction, + RetrieveTokenAction } from './auth.actions'; -import { AuthServiceStub } from '../../shared/testing/auth-service-stub'; +import { authMethodsMock, AuthServiceStub } from '../../shared/testing/auth-service-stub'; import { AuthService } from './auth.service'; import { AuthState } from './auth.reducer'; - import { EPersonMock } from '../../shared/testing/eperson-mock'; +import { AuthStatus } from './models/auth-status.model'; describe('AuthEffects', () => { let authEffects: AuthEffects; @@ -42,13 +49,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 +71,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 +88,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 +102,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 +114,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 +126,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 +171,96 @@ 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 CheckAuthenticationTokenCookieAction() }); expect(authEffects.checkToken$).toBeObservable(expected); }); }) }); + describe('checkTokenCookie$', () => { + + describe('when check token succeeded', () => { + it('should return a RETRIEVE_TOKEN action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is true', () => { + spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue( + observableOf( + { + authenticated: true + }) + ); + actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } }); + + const expected = cold('--b-', { b: new RetrieveTokenAction() }); + + expect(authEffects.checkTokenCookie$).toBeObservable(expected); + }); + + it('should return a RETRIEVE_AUTH_METHODS action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is false', () => { + spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue( + observableOf( + { authenticated: false }) + ); + actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } }); + + const expected = cold('--b-', { b: new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus) }); + + expect(authEffects.checkTokenCookie$).toBeObservable(expected); + }); + }); + + describe('when check token failed', () => { + it('should return a AUTHENTICATED_ERROR action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action', () => { + spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue(observableThrow(new Error('Message Error test'))); + + actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE, payload: token } }); + + const expected = cold('--b-', { b: new AuthenticatedErrorAction(new Error('Message Error test')) }); + + expect(authEffects.checkTokenCookie$).toBeObservable(expected); + }); + }) + }); + + describe('retrieveAuthenticatedEperson$', () => { + + describe('when request is successful', () => { + it('should return a RETRIEVE_AUTHENTICATED_EPERSON_SUCCESS action in response to a RETRIEVE_AUTHENTICATED_EPERSON action', () => { + actions = hot('--a-', { + a: { + type: AuthActionTypes.RETRIEVE_AUTHENTICATED_EPERSON, + payload: EPersonMock._links.self.href + } + }); + + const expected = cold('--b-', { b: new RetrieveAuthenticatedEpersonSuccessAction(EPersonMock) }); + + expect(authEffects.retrieveAuthenticatedEperson$).toBeObservable(expected); + }); + }); + + describe('when request is not successful', () => { + it('should return a RETRIEVE_AUTHENTICATED_EPERSON_ERROR action in response to a RETRIEVE_AUTHENTICATED_EPERSON action', () => { + spyOn((authEffects as any).authService, 'retrieveAuthenticatedUserByHref').and.returnValue(observableThrow(new Error('Message Error test'))); + + actions = hot('--a-', { a: { type: AuthActionTypes.RETRIEVE_AUTHENTICATED_EPERSON, payload: token } }); + + const expected = cold('--b-', { b: new RetrieveAuthenticatedEpersonErrorAction(new Error('Message Error test')) }); + + expect(authEffects.retrieveAuthenticatedEperson$).toBeObservable(expected); + }); + }); + }); + describe('refreshToken$', () => { describe('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,23 +270,55 @@ 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); }); }) }); + describe('retrieveToken$', () => { + describe('when user is authenticated', () => { + it('should return a AUTHENTICATE_SUCCESS action in response to a RETRIEVE_TOKEN action', () => { + actions = hot('--a-', { + a: { + type: AuthActionTypes.RETRIEVE_TOKEN + } + }); + + const expected = cold('--b-', { b: new AuthenticationSuccessAction(token) }); + + expect(authEffects.retrieveToken$).toBeObservable(expected); + }); + }); + + describe('when user is not authenticated', () => { + it('should return a AUTHENTICATE_ERROR action in response to a RETRIEVE_TOKEN action', () => { + spyOn((authEffects as any).authService, 'refreshAuthenticationToken').and.returnValue(observableThrow(new Error('Message Error test'))); + + actions = hot('--a-', { + a: { + type: AuthActionTypes.RETRIEVE_TOKEN + } + }); + + const expected = cold('--b-', { b: new AuthenticationErrorAction(new Error('Message Error test')) }); + + expect(authEffects.retrieveToken$).toBeObservable(expected); + }); + }); + }); + describe('logOut$', () => { 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,12 +328,37 @@ 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); }); }) }); + + describe('retrieveMethods$', () => { + + describe('when retrieve authentication methods succeeded', () => { + it('should return a RETRIEVE_AUTH_METHODS_SUCCESS action in response to a RETRIEVE_AUTH_METHODS action', () => { + actions = hot('--a-', { a: { type: AuthActionTypes.RETRIEVE_AUTH_METHODS } }); + + const expected = cold('--b-', { b: new RetrieveAuthMethodsSuccessAction(authMethodsMock) }); + + expect(authEffects.retrieveMethods$).toBeObservable(expected); + }); + }); + + describe('when retrieve authentication methods failed', () => { + it('should return a RETRIEVE_AUTH_METHODS_ERROR action in response to a RETRIEVE_AUTH_METHODS action', () => { + spyOn((authEffects as any).authService, 'retrieveAuthMethodsFromAuthStatus').and.returnValue(observableThrow('')); + + actions = hot('--a-', { a: { type: AuthActionTypes.RETRIEVE_AUTH_METHODS } }); + + const expected = cold('--b-', { b: new RetrieveAuthMethodsErrorAction() }); + + expect(authEffects.retrieveMethods$).toBeObservable(expected); + }); + }) + }); }); diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index 1e68802af8..d153748fb9 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -1,6 +1,6 @@ -import { of as observableOf, Observable } from 'rxjs'; +import { Observable, of as observableOf } from 'rxjs'; -import { filter, debounceTime, switchMap, take, tap, catchError, map } from 'rxjs/operators'; +import { catchError, debounceTime, filter, map, switchMap, take, tap } from 'rxjs/operators'; import { Injectable } from '@angular/core'; // import @ngrx @@ -9,6 +9,14 @@ import { Action, select, Store } from '@ngrx/store'; // import services import { AuthService } from './auth.service'; + +import { EPerson } from '../eperson/models/eperson.model'; +import { AuthStatus } from './models/auth-status.model'; +import { AuthTokenInfo } from './models/auth-token-info.model'; +import { AppState } from '../../app.reducer'; +import { isAuthenticated } from './selectors'; +import { StoreActionTypes } from '../../store.actions'; +import { AuthMethod } from './models/auth.method'; // import actions import { AuthActionTypes, @@ -18,7 +26,7 @@ import { AuthenticatedSuccessAction, AuthenticationErrorAction, AuthenticationSuccessAction, - CheckAuthenticationTokenErrorAction, + CheckAuthenticationTokenCookieAction, LogOutErrorAction, LogOutSuccessAction, RefreshTokenAction, @@ -26,14 +34,15 @@ import { RefreshTokenSuccessAction, RegistrationAction, RegistrationErrorAction, - RegistrationSuccessAction + RegistrationSuccessAction, + RetrieveAuthenticatedEpersonAction, + RetrieveAuthenticatedEpersonErrorAction, + RetrieveAuthenticatedEpersonSuccessAction, + RetrieveAuthMethodsAction, + RetrieveAuthMethodsErrorAction, + RetrieveAuthMethodsSuccessAction, + RetrieveTokenAction } from './auth.actions'; -import { EPerson } from '../eperson/models/eperson.model'; -import { AuthStatus } from './models/auth-status.model'; -import { AuthTokenInfo } from './models/auth-token-info.model'; -import { AppState } from '../../app.reducer'; -import { isAuthenticated } from './selectors'; -import { StoreActionTypes } from '../../store.actions'; @Injectable() export class AuthEffects { @@ -44,78 +53,123 @@ export class AuthEffects { */ @Effect() public authenticate$: Observable = this.actions$.pipe( - ofType(AuthActionTypes.AUTHENTICATE), - switchMap((action: AuthenticateAction) => { - return this.authService.authenticate(action.payload.email, action.payload.password).pipe( - take(1), - map((response: AuthStatus) => new AuthenticationSuccessAction(response.token)), - catchError((error) => observableOf(new AuthenticationErrorAction(error))) - ); - }) - ); + ofType(AuthActionTypes.AUTHENTICATE), + switchMap((action: AuthenticateAction) => { + return this.authService.authenticate(action.payload.email, action.payload.password).pipe( + take(1), + map((response: AuthStatus) => new AuthenticationSuccessAction(response.token)), + catchError((error) => observableOf(new AuthenticationErrorAction(error))) + ); + }) + ); @Effect() public authenticateSuccess$: Observable = this.actions$.pipe( - ofType(AuthActionTypes.AUTHENTICATE_SUCCESS), - tap((action: AuthenticationSuccessAction) => this.authService.storeToken(action.payload)), - map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload)) - ); + ofType(AuthActionTypes.AUTHENTICATE_SUCCESS), + tap((action: AuthenticationSuccessAction) => this.authService.storeToken(action.payload)), + map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload)) + ); @Effect() public authenticated$: Observable = this.actions$.pipe( - ofType(AuthActionTypes.AUTHENTICATED), - switchMap((action: AuthenticatedAction) => { - return this.authService.authenticatedUser(action.payload).pipe( - map((user: EPerson) => new AuthenticatedSuccessAction((user !== null), action.payload, user)), - catchError((error) => observableOf(new AuthenticatedErrorAction(error))),); - }) - ); + ofType(AuthActionTypes.AUTHENTICATED), + switchMap((action: AuthenticatedAction) => { + return this.authService.authenticatedUser(action.payload).pipe( + 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( - ofType(AuthActionTypes.AUTHENTICATED_ERROR), - tap((action: LogOutSuccessAction) => this.authService.removeToken()) - ); + ofType(AuthActionTypes.AUTHENTICATED_ERROR), + 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(() => { - return this.authService.hasValidAuthenticationToken().pipe( - map((token: AuthTokenInfo) => new AuthenticatedAction(token)), - catchError((error) => observableOf(new CheckAuthenticationTokenErrorAction())) - ); - }) - ); + switchMap(() => { + return this.authService.hasValidAuthenticationToken().pipe( + map((token: AuthTokenInfo) => new AuthenticatedAction(token)), + catchError((error) => observableOf(new CheckAuthenticationTokenCookieAction())) + ); + }) + ); + + @Effect() + public checkTokenCookie$: Observable = this.actions$.pipe( + ofType(AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE), + switchMap(() => { + return this.authService.checkAuthenticationCookie().pipe( + map((response: AuthStatus) => { + if (response.authenticated) { + return new RetrieveTokenAction(); + } else { + return new RetrieveAuthMethodsAction(response); + } + }), + catchError((error) => observableOf(new AuthenticatedErrorAction(error))) + ); + }) + ); @Effect() public createUser$: Observable = this.actions$.pipe( - ofType(AuthActionTypes.REGISTRATION), - debounceTime(500), // to remove when functionality is implemented - switchMap((action: RegistrationAction) => { - return this.authService.create(action.payload).pipe( - map((user: EPerson) => new RegistrationSuccessAction(user)), - catchError((error) => observableOf(new RegistrationErrorAction(error))) - ); - }) - ); + ofType(AuthActionTypes.REGISTRATION), + debounceTime(500), // to remove when functionality is implemented + switchMap((action: RegistrationAction) => { + return this.authService.create(action.payload).pipe( + map((user: EPerson) => new RegistrationSuccessAction(user)), + catchError((error) => observableOf(new RegistrationErrorAction(error))) + ); + }) + ); + + @Effect() + public retrieveToken$: Observable = this.actions$.pipe( + ofType(AuthActionTypes.RETRIEVE_TOKEN), + switchMap((action: AuthenticateAction) => { + return this.authService.refreshAuthenticationToken(null).pipe( + take(1), + map((token: AuthTokenInfo) => new AuthenticationSuccessAction(token)), + catchError((error) => observableOf(new AuthenticationErrorAction(error))) + ); + }) + ); @Effect() public refreshToken$: Observable = this.actions$.pipe(ofType(AuthActionTypes.REFRESH_TOKEN), - switchMap((action: RefreshTokenAction) => { - return this.authService.refreshAuthenticationToken(action.payload).pipe( - map((token: AuthTokenInfo) => new RefreshTokenSuccessAction(token)), - catchError((error) => observableOf(new RefreshTokenErrorAction())) - ); - }) - ); + switchMap((action: RefreshTokenAction) => { + return this.authService.refreshAuthenticationToken(action.payload).pipe( + map((token: AuthTokenInfo) => new RefreshTokenSuccessAction(token)), + catchError((error) => observableOf(new RefreshTokenErrorAction())) + ); + }) + ); // It means "reacts to this action but don't send another" @Effect({ dispatch: false }) public refreshTokenSuccess$: Observable = this.actions$.pipe( - ofType(AuthActionTypes.REFRESH_TOKEN_SUCCESS), - tap((action: RefreshTokenSuccessAction) => this.authService.replaceToken(action.payload)) - ); + ofType(AuthActionTypes.REFRESH_TOKEN_SUCCESS), + tap((action: RefreshTokenSuccessAction) => this.authService.replaceToken(action.payload)) + ); /** * When the store is rehydrated in the browser, @@ -169,6 +223,19 @@ export class AuthEffects { tap(() => this.authService.redirectToLoginWhenTokenExpired()) ); + @Effect() + public retrieveMethods$: Observable = this.actions$ + .pipe( + ofType(AuthActionTypes.RETRIEVE_AUTH_METHODS), + switchMap((action: RetrieveAuthMethodsAction) => { + return this.authService.retrieveAuthMethodsFromAuthStatus(action.payload) + .pipe( + map((authMethodModels: AuthMethod[]) => new RetrieveAuthMethodsSuccessAction(authMethodModels)), + catchError((error) => observableOf(new RetrieveAuthMethodsErrorAction())) + ) + }) + ); + /** * @constructor * @param {Actions} actions$ diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index 08e892bbd9..6d609a4ea3 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -6,6 +6,7 @@ import { HttpErrorResponse, HttpEvent, HttpHandler, + HttpHeaders, HttpInterceptor, HttpRequest, HttpResponse, @@ -17,10 +18,12 @@ import { AppState } from '../../app.reducer'; import { AuthService } from './auth.service'; import { AuthStatus } from './models/auth-status.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; -import { isNotEmpty, isUndefined, isNotNull } from '../../shared/empty.util'; +import { isNotEmpty, isNotNull, isUndefined } from '../../shared/empty.util'; import { RedirectWhenTokenExpiredAction, RefreshTokenAction } from './auth.actions'; import { Store } from '@ngrx/store'; import { Router } from '@angular/router'; +import { AuthMethod } from './models/auth.method'; +import { AuthMethodType } from './models/auth.method-type'; @Injectable() export class AuthInterceptor implements HttpInterceptor { @@ -30,17 +33,33 @@ export class AuthInterceptor implements HttpInterceptor { // we're creating a refresh token request list protected refreshTokenRequestUrls = []; - constructor(private inj: Injector, private router: Router, private store: Store) { } + constructor(private inj: Injector, private router: Router, private store: Store) { + } + /** + * Check if response status code is 401 + * + * @param response + */ private isUnauthorized(response: HttpResponseBase): boolean { // invalid_token The access token provided is expired, revoked, malformed, or invalid for other reasons return response.status === 401; } + /** + * Check if response status code is 200 or 204 + * + * @param response + */ private isSuccess(response: HttpResponseBase): boolean { return (response.status === 200 || response.status === 204); } + /** + * Check if http request is to authn endpoint + * + * @param http + */ private isAuthRequest(http: HttpRequest | HttpResponseBase): boolean { return http && http.url && (http.url.endsWith('/authn/login') @@ -48,18 +67,131 @@ export class AuthInterceptor implements HttpInterceptor { || http.url.endsWith('/authn/status')); } + /** + * Check if response is from a login request + * + * @param http + */ private isLoginResponse(http: HttpRequest | HttpResponseBase): boolean { - return http.url && http.url.endsWith('/authn/login'); + return http.url && http.url.endsWith('/authn/login') } + /** + * Check if response is from a logout request + * + * @param http + */ private isLogoutResponse(http: HttpRequest | HttpResponseBase): boolean { return http.url && http.url.endsWith('/authn/logout'); } - private makeAuthStatusObject(authenticated: boolean, accessToken?: string, error?: string): AuthStatus { + /** + * Check if response is from a status request + * + * @param http + */ + private isStatusResponse(http: HttpRequest | HttpResponseBase): boolean { + return http.url && http.url.endsWith('/authn/status'); + } + + /** + * Extract location url from the WWW-Authenticate header + * + * @param header + */ + private parseLocation(header: string): string { + let location = header.trim(); + location = location.replace('location="', ''); + location = location.replace('"', ''); + let re = /%3A%2F%2F/g; + location = location.replace(re, '://'); + re = /%3A/g; + location = location.replace(re, ':'); + return location.trim(); + } + + /** + * Sort authentication methods list + * + * @param authMethodModels + */ + private sortAuthMethods(authMethodModels: AuthMethod[]): AuthMethod[] { + const sortedAuthMethodModels: AuthMethod[] = []; + authMethodModels.forEach((method) => { + if (method.authMethodType === AuthMethodType.Password) { + sortedAuthMethodModels.push(method); + } + }); + + authMethodModels.forEach((method) => { + if (method.authMethodType !== AuthMethodType.Password) { + sortedAuthMethodModels.push(method); + } + }); + + return sortedAuthMethodModels; + } + + /** + * Extract authentication methods list from the WWW-Authenticate headers + * + * @param headers + */ + private parseAuthMethodsFromHeaders(headers: HttpHeaders): AuthMethod[] { + let authMethodModels: AuthMethod[] = []; + if (isNotEmpty(headers.get('www-authenticate'))) { + // get the realms from the header - a realm is a single auth method + const completeWWWauthenticateHeader = headers.get('www-authenticate'); + const regex = /(\w+ (\w+=((".*?")|[^,]*)(, )?)*)/g; + const realms = completeWWWauthenticateHeader.match(regex); + + // tslint:disable-next-line:forin + for (const j in realms) { + + const splittedRealm = realms[j].split(', '); + const methodName = splittedRealm[0].split(' ')[0].trim(); + + let authMethodModel: AuthMethod; + if (splittedRealm.length === 1) { + authMethodModel = new AuthMethod(methodName); + authMethodModels.push(authMethodModel); + } else if (splittedRealm.length > 1) { + let location = splittedRealm[1]; + location = this.parseLocation(location); + authMethodModel = new AuthMethod(methodName, location); + authMethodModels.push(authMethodModel); + } + } + + // make sure the email + password login component gets rendered first + authMethodModels = this.sortAuthMethods(authMethodModels); + } else { + authMethodModels.push(new AuthMethod(AuthMethodType.Password)); + } + + return authMethodModels; + } + + /** + * Generate an AuthStatus object + * + * @param authenticated + * @param accessToken + * @param error + * @param httpHeaders + */ + private makeAuthStatusObject(authenticated: boolean, accessToken?: string, error?: string, httpHeaders?: HttpHeaders): AuthStatus { const authStatus = new AuthStatus(); + // let authMethods: AuthMethodModel[]; + if (httpHeaders) { + authStatus.authMethods = this.parseAuthMethodsFromHeaders(httpHeaders); + } + authStatus.id = null; + authStatus.okay = true; + // authStatus.authMethods = authMethods; + if (authenticated) { authStatus.authenticated = true; authStatus.token = new AuthTokenInfo(accessToken); @@ -70,12 +202,18 @@ export class AuthInterceptor implements HttpInterceptor { return authStatus; } + /** + * Intercept method + * @param req + * @param next + */ intercept(req: HttpRequest, next: HttpHandler): Observable> { const authService = this.inj.get(AuthService); - const token = authService.getToken(); - let newReq; + const token: AuthTokenInfo = authService.getToken(); + let newReq: HttpRequest; + let authorization: string; if (authService.isTokenExpired()) { authService.setRedirectUrl(this.router.url); @@ -96,30 +234,41 @@ export class AuthInterceptor implements HttpInterceptor { } }); // Get the auth header from the service. - const Authorization = authService.buildAuthHeader(token); + authorization = authService.buildAuthHeader(token); // Clone the request to add the new header. - newReq = req.clone({headers: req.headers.set('authorization', Authorization)}); + newReq = req.clone({ headers: req.headers.set('authorization', authorization) }); } else { - newReq = req; + newReq = req.clone(); } // Pass on the new request instead of the original request. return next.handle(newReq).pipe( + // tap((response) => console.log('next.handle: ', response)), map((response) => { // Intercept a Login/Logout response - if (response instanceof HttpResponse && this.isSuccess(response) && (this.isLoginResponse(response) || this.isLogoutResponse(response))) { + if (response instanceof HttpResponse && this.isSuccess(response) && this.isAuthRequest(response)) { // It's a success Login/Logout response let authRes: HttpResponse; if (this.isLoginResponse(response)) { // login successfully const newToken = response.headers.get('authorization'); - authRes = response.clone({body: this.makeAuthStatusObject(true, newToken)}); + authRes = response.clone({ + body: this.makeAuthStatusObject(true, newToken) + }); // clean eventually refresh Requests list this.refreshTokenRequestUrls = []; + } else if (this.isStatusResponse(response)) { + authRes = response.clone({ + body: Object.assign(response.body, { + authMethods: this.parseAuthMethodsFromHeaders(response.headers) + }) + }) } else { // logout successfully - authRes = response.clone({body: this.makeAuthStatusObject(false)}); + authRes = response.clone({ + body: this.makeAuthStatusObject(false) + }); } return authRes; } else { @@ -129,13 +278,15 @@ export class AuthInterceptor implements HttpInterceptor { catchError((error, caught) => { // Intercept an error response if (error instanceof HttpErrorResponse) { + // Checks if is a response from a request to an authentication endpoint if (this.isAuthRequest(error)) { // clean eventually refresh Requests list this.refreshTokenRequestUrls = []; + // Create a new HttpResponse and return it, so it can be handle properly by AuthService. const authResponse = new HttpResponse({ - body: this.makeAuthStatusObject(false, null, error.error), + body: this.makeAuthStatusObject(false, null, error.error, error.headers), headers: error.headers, status: error.status, statusText: error.statusText, diff --git a/src/app/core/auth/auth.reducer.spec.ts b/src/app/core/auth/auth.reducer.spec.ts index ca2ba00036..7a39ef3da4 100644 --- a/src/app/core/auth/auth.reducer.spec.ts +++ b/src/app/core/auth/auth.reducer.spec.ts @@ -8,7 +8,7 @@ import { AuthenticationErrorAction, AuthenticationSuccessAction, CheckAuthenticationTokenAction, - CheckAuthenticationTokenErrorAction, + CheckAuthenticationTokenCookieAction, LogOutAction, LogOutErrorAction, LogOutSuccessAction, @@ -18,10 +18,18 @@ import { RefreshTokenErrorAction, RefreshTokenSuccessAction, ResetAuthenticationMessagesAction, + RetrieveAuthenticatedEpersonErrorAction, + RetrieveAuthenticatedEpersonSuccessAction, + RetrieveAuthMethodsAction, + RetrieveAuthMethodsErrorAction, + RetrieveAuthMethodsSuccessAction, SetRedirectUrlAction } from './auth.actions'; import { AuthTokenInfo } from './models/auth-token-info.model'; import { EPersonMock } from '../../shared/testing/eperson-mock'; +import { AuthStatus } from './models/auth-status.model'; +import { AuthMethod } from './models/auth.method'; +import { AuthMethodType } from './models/auth.method-type'; describe('authReducer', () => { @@ -107,16 +115,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); }); @@ -158,18 +165,18 @@ describe('authReducer', () => { expect(newState).toEqual(state); }); - it('should properly set the state, in response to a CHECK_AUTHENTICATION_TOKEN_ERROR action', () => { + it('should properly set the state, in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action', () => { initialState = { authenticated: false, loaded: false, loading: true, }; - const action = new CheckAuthenticationTokenErrorAction(); + const action = new CheckAuthenticationTokenCookieAction(); const newState = authReducer(initialState, action); state = { authenticated: false, loaded: false, - loading: false, + loading: true, }; expect(newState).toEqual(state); }); @@ -242,6 +249,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, @@ -408,4 +459,63 @@ describe('authReducer', () => { }; expect(newState).toEqual(state); }); + + it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS action', () => { + initialState = { + authenticated: false, + loaded: false, + loading: false, + authMethods: [] + }; + const action = new RetrieveAuthMethodsAction(new AuthStatus()); + const newState = authReducer(initialState, action); + state = { + authenticated: false, + loaded: false, + loading: true, + authMethods: [] + }; + expect(newState).toEqual(state); + }); + + it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_SUCCESS action', () => { + initialState = { + authenticated: false, + loaded: false, + loading: true, + authMethods: [] + }; + const authMethods = [ + new AuthMethod(AuthMethodType.Password), + new AuthMethod(AuthMethodType.Shibboleth, 'location') + ]; + const action = new RetrieveAuthMethodsSuccessAction(authMethods); + const newState = authReducer(initialState, action); + state = { + authenticated: false, + loaded: false, + loading: false, + authMethods: authMethods + }; + expect(newState).toEqual(state); + }); + + it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_ERROR action', () => { + initialState = { + authenticated: false, + loaded: false, + loading: true, + authMethods: [] + }; + + const action = new RetrieveAuthMethodsErrorAction(); + const newState = authReducer(initialState, action); + state = { + authenticated: false, + loaded: false, + loading: false, + authMethods: [new AuthMethod(AuthMethodType.Password)] + }; + expect(newState).toEqual(state); + }); }); diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index 98827d842e..19fd162d3f 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -9,11 +9,15 @@ import { RedirectWhenAuthenticationIsRequiredAction, RedirectWhenTokenExpiredAction, RefreshTokenSuccessAction, + RetrieveAuthenticatedEpersonSuccessAction, + RetrieveAuthMethodsSuccessAction, SetRedirectUrlAction } from './auth.actions'; // import models import { EPerson } from '../eperson/models/eperson.model'; import { AuthTokenInfo } from './models/auth-token-info.model'; +import { AuthMethod } from './models/auth.method'; +import { AuthMethodType } from './models/auth.method-type'; /** * The auth state. @@ -47,6 +51,10 @@ export interface AuthState { // the authenticated user user?: EPerson; + + // all authentication Methods enabled at the backend + authMethods?: AuthMethod[]; + } /** @@ -56,6 +64,7 @@ const initialState: AuthState = { authenticated: false, loaded: false, loading: false, + authMethods: [] }; /** @@ -75,11 +84,14 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut }); case AuthActionTypes.AUTHENTICATED: + case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN: + case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE: return Object.assign({}, state, { loading: true }); case AuthActionTypes.AUTHENTICATED_ERROR: + case AuthActionTypes.RETRIEVE_AUTHENTICATED_EPERSON_ERROR: return Object.assign({}, state, { authenticated: false, authToken: undefined, @@ -91,12 +103,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: @@ -108,21 +124,10 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut loading: false }); - case AuthActionTypes.AUTHENTICATED: case AuthActionTypes.AUTHENTICATE_SUCCESS: case AuthActionTypes.LOG_OUT: return state; - case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN: - return Object.assign({}, state, { - loading: true - }); - - case AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_ERROR: - return Object.assign({}, state, { - loading: false - }); - case AuthActionTypes.LOG_OUT_ERROR: return Object.assign({}, state, { authenticated: true, @@ -187,6 +192,24 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut info: undefined, }); + // next three cases are used by dynamic rendering of login methods + case AuthActionTypes.RETRIEVE_AUTH_METHODS: + return Object.assign({}, state, { + loading: true + }); + + case AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS: + return Object.assign({}, state, { + loading: false, + authMethods: (action as RetrieveAuthMethodsSuccessAction).payload + }); + + case AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR: + return Object.assign({}, state, { + loading: false, + authMethods: [new AuthMethod(AuthMethodType.Password)] + }); + case AuthActionTypes.SET_REDIRECT_URL: return Object.assign({}, state, { redirectUrl: (action as SetRedirectUrlAction).payload, diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index 0928afcb19..03759987bf 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,23 @@ 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'; +import { authMethodsMock } from '../../shared/testing/auth-service-stub'; +import { AuthMethod } from './models/auth.method'; describe('AuthService test', () => { + const mockEpersonDataService: any = { + findByHref(href: string): Observable> { + return createSuccessfulRemoteDataObject$(EPersonMock); + } + }; + let mockStore: Store; let authService: AuthService; let routeServiceMock: RouteService; @@ -62,7 +71,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 +92,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 +110,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(); }); }); @@ -131,6 +146,26 @@ describe('AuthService test', () => { expect(authService.logout.bind(null)).toThrow(); }); + it('should return the authentication status object to check an Authentication Cookie', () => { + authService.checkAuthenticationCookie().subscribe((status: AuthStatus) => { + expect(status).toBeDefined(); + }); + }); + + it('should return the authentication methods available', () => { + const authStatus = new AuthStatus(); + + authService.retrieveAuthMethodsFromAuthStatus(authStatus).subscribe((authMethods: AuthMethod[]) => { + expect(authMethods).toBeDefined(); + expect(authMethods.length).toBe(0); + }); + + authStatus.authMethods = authMethodsMock; + authService.retrieveAuthMethodsFromAuthStatus(authStatus).subscribe((authMethods: AuthMethod[]) => { + expect(authMethods).toBeDefined(); + expect(authMethods.length).toBe(2); + }); + }); }); describe('', () => { @@ -159,7 +194,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 +256,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 +285,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 +293,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 +301,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 +309,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..0f5c06bbc9 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'; @@ -20,14 +18,20 @@ import { isEmpty, isNotEmpty, isNotNull, isNotUndefined } from '../../shared/emp import { CookieService } from '../services/cookie.service'; import { getAuthenticationToken, getRedirectUrl, isAuthenticated, isTokenRefreshing } from './selectors'; import { AppState, routerStateSelector } from '../../app.reducer'; -import { ResetAuthenticationMessagesAction, SetRedirectUrlAction } from './auth.actions'; +import { + CheckAuthenticationTokenAction, + ResetAuthenticationMessagesAction, + SetRedirectUrlAction +} from './auth.actions'; 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 { getAllSucceededRemoteDataPayload } from '../shared/operators'; +import { AuthMethod } from './models/auth.method'; export const LOGIN_ROUTE = '/login'; export const LOGOUT_ROUTE = '/logout'; - export const REDIRECT_COOKIE = 'dsRedirectUrl'; /** @@ -44,13 +48,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), @@ -114,6 +118,21 @@ export class AuthService { } + /** + * Checks if token is present into the request cookie + */ + public checkAuthenticationCookie(): Observable { + // Determine if the user has an existing auth session on the server + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Accept', 'application/json'); + options.headers = headers; + options.withCredentials = true; + return this.authRequestService.getRequest('status', options).pipe( + map((status: AuthStatus) => Object.assign(new AuthStatus(), status)) + ); + } + /** * Determines if the user is authenticated * @returns {Observable} @@ -123,10 +142,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,10 +153,9 @@ 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')); } @@ -145,10 +163,20 @@ export class AuthService { } /** - * Checks if token is present into browser storage and is valid. (NB Check is done only on SSR) + * Returns the authenticated user + * @returns {User} + */ + public retrieveAuthenticatedUserByHref(userHref: string): Observable { + return this.epersonService.findByHref(userHref).pipe( + getAllSucceededRemoteDataPayload() + ) + } + + /** + * Checks if token is present into browser storage and is valid. */ public checkAuthenticationToken() { - return + this.store.dispatch(new CheckAuthenticationTokenAction()); } /** @@ -178,8 +206,11 @@ export class AuthService { const options: HttpOptions = Object.create({}); let headers = new HttpHeaders(); headers = headers.append('Accept', 'application/json'); - headers = headers.append('Authorization', `Bearer ${token.accessToken}`); + if (token && token.accessToken) { + headers = headers.append('Authorization', `Bearer ${token.accessToken}`); + } options.headers = headers; + options.withCredentials = true; return this.authRequestService.postToEndpoint('login', {}, options).pipe( map((status: AuthStatus) => { if (status.authenticated) { @@ -197,6 +228,18 @@ export class AuthService { this.store.dispatch(new ResetAuthenticationMessagesAction()); } + /** + * Retrieve authentication methods available + * @returns {User} + */ + public retrieveAuthMethodsFromAuthStatus(status: AuthStatus): Observable { + let authMethods: AuthMethod[] = []; + if (isNotEmpty(status.authMethods)) { + authMethods = status.authMethods; + } + return observableOf(authMethods); + } + /** * Create a new user * @returns {User} diff --git a/src/app/core/auth/authenticated.guard.ts b/src/app/core/auth/authenticated.guard.ts index af0622cd19..7a2f39854c 100644 --- a/src/app/core/auth/authenticated.guard.ts +++ b/src/app/core/auth/authenticated.guard.ts @@ -1,17 +1,14 @@ - -import {take} from 'rxjs/operators'; import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, CanActivate, CanLoad, Route, Router, RouterStateSnapshot } from '@angular/router'; -import {Observable, of} from 'rxjs'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; import { select, Store } from '@ngrx/store'; -// reducers import { CoreState } from '../core.reducers'; -import { isAuthenticated, isAuthenticationLoading } from './selectors'; +import { isAuthenticated } from './selectors'; import { AuthService } from './auth.service'; import { RedirectWhenAuthenticationIsRequiredAction } from './auth.actions'; -import { isEmpty } from '../../shared/empty.util'; /** * Prevent unauthorized activating and loading of routes diff --git a/src/app/core/auth/models/auth-status.model.ts b/src/app/core/auth/models/auth-status.model.ts index edad46a7bc..197c025407 100644 --- a/src/app/core/auth/models/auth-status.model.ts +++ b/src/app/core/auth/models/auth-status.model.ts @@ -12,6 +12,7 @@ import { excludeFromEquals } from '../../utilities/equals.decorators'; import { AuthError } from './auth-error.model'; import { AUTH_STATUS } from './auth-status.resource-type'; import { AuthTokenInfo } from './auth-token-info.model'; +import { AuthMethod } from './auth.method'; /** * Object that represents the authenticated status of a user @@ -79,5 +80,13 @@ export class AuthStatus implements CacheableObject { * Authentication error if there was one for this status */ // TODO should be refactored to use the RemoteData error + @autoserialize error?: AuthError; + + /** + * All authentication methods enabled at the backend + */ + @autoserialize + authMethods: AuthMethod[]; + } diff --git a/src/app/core/auth/models/auth.method-type.ts b/src/app/core/auth/models/auth.method-type.ts new file mode 100644 index 0000000000..f053515065 --- /dev/null +++ b/src/app/core/auth/models/auth.method-type.ts @@ -0,0 +1,7 @@ +export enum AuthMethodType { + Password = 'password', + Shibboleth = 'shibboleth', + Ldap = 'ldap', + Ip = 'ip', + X509 = 'x509' +} diff --git a/src/app/core/auth/models/auth.method.ts b/src/app/core/auth/models/auth.method.ts new file mode 100644 index 0000000000..617154080b --- /dev/null +++ b/src/app/core/auth/models/auth.method.ts @@ -0,0 +1,38 @@ +import { AuthMethodType } from './auth.method-type'; + +export class AuthMethod { + authMethodType: AuthMethodType; + location?: string; + + // isStandalonePage? = true; + + constructor(authMethodName: string, location?: string) { + switch (authMethodName) { + case 'ip': { + this.authMethodType = AuthMethodType.Ip; + break; + } + case 'ldap': { + this.authMethodType = AuthMethodType.Ldap; + break; + } + case 'shibboleth': { + this.authMethodType = AuthMethodType.Shibboleth; + this.location = location; + break; + } + case 'x509': { + this.authMethodType = AuthMethodType.X509; + break; + } + case 'password': { + this.authMethodType = AuthMethodType.Password; + break; + } + + default: { + break; + } + } + } +} diff --git a/src/app/core/auth/models/normalized-auth-status.model.ts b/src/app/core/auth/models/normalized-auth-status.model.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/core/auth/selectors.ts b/src/app/core/auth/selectors.ts index 8c88e0fce5..4e51bc1fc9 100644 --- a/src/app/core/auth/selectors.ts +++ b/src/app/core/auth/selectors.ts @@ -107,6 +107,17 @@ const _getRegistrationError = (state: AuthState) => state.error; */ const _getRedirectUrl = (state: AuthState) => state.redirectUrl; +const _getAuthenticationMethods = (state: AuthState) => state.authMethods; + +/** + * Returns the authentication methods enabled at the backend + * @function getAuthenticationMethods + * @param {AuthState} state + * @param {any} props + * @return {any} + */ +export const getAuthenticationMethods = createSelector(getAuthState, _getAuthenticationMethods); + /** * Returns the authenticated user * @function getAuthenticatedUser diff --git a/src/app/core/auth/server-auth.service.ts b/src/app/core/auth/server-auth.service.ts index eea2d83867..30767be85a 100644 --- a/src/app/core/auth/server-auth.service.ts +++ b/src/app/core/auth/server-auth.service.ts @@ -1,13 +1,11 @@ -import { HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; +import { HttpHeaders } from '@angular/common/http'; 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'; import { AuthTokenInfo } from './models/auth-token-info.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')); } @@ -46,10 +43,23 @@ export class ServerAuthService extends AuthService { } /** - * Checks if token is present into browser storage and is valid. (NB Check is done only on SSR) + * Checks if token is present into the request cookie */ - public checkAuthenticationToken() { - this.store.dispatch(new CheckAuthenticationTokenAction()) + public checkAuthenticationCookie(): Observable { + // Determine if the user has an existing auth session on the server + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Accept', 'application/json'); + if (isNotEmpty(this.req.protocol) && isNotEmpty(this.req.header('host'))) { + const referer = this.req.protocol + '://' + this.req.header('host') + this.req.path; + // use to allow the rest server to identify the real origin on SSR + headers = headers.append('X-Requested-With', referer); + } + options.headers = headers; + options.withCredentials = true; + return this.authRequestService.getRequest('status', options).pipe( + map((status: AuthStatus) => Object.assign(new AuthStatus(), status)) + ); } /** 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..7384a031db --- /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, true, + 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..cd0c23cf82 --- /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, true, + followLink('parentCommunity', undefined, true, + followLink('parentCommunity')) + ), + followLink('bundles'), + followLink('relationships') + ]; + } +} diff --git a/src/app/core/cache/builders/link.service.spec.ts b/src/app/core/cache/builders/link.service.spec.ts index b34aea320a..e9b8447c22 100644 --- a/src/app/core/cache/builders/link.service.spec.ts +++ b/src/app/core/cache/builders/link.service.spec.ts @@ -90,7 +90,7 @@ describe('LinkService', () => { propertyName: 'predecessor' }); spyOnFunction(decorators, 'getDataServiceFor').and.returnValue(TestDataService); - service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor'))) + service.resolveLink(testModel, followLink('predecessor', {}, true, followLink('successor'))) }); it('should call dataservice.findByHref with the correct href and nested links', () => { expect(testDataService.findByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, followLink('successor')); @@ -105,7 +105,7 @@ describe('LinkService', () => { isList: true }); spyOnFunction(decorators, 'getDataServiceFor').and.returnValue(TestDataService); - service.resolveLink(testModel, followLink('predecessor', { some: 'options '} as any, followLink('successor'))) + service.resolveLink(testModel, followLink('predecessor', { some: 'options '} as any, true, followLink('successor'))) }); it('should call dataservice.findAllByHref with the correct href, findListOptions, and nested links', () => { expect(testDataService.findAllByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, { some: 'options '} as any, followLink('successor')); @@ -119,7 +119,7 @@ describe('LinkService', () => { propertyName: 'predecessor' }); spyOnFunction(decorators, 'getDataServiceFor').and.returnValue(TestDataService); - result = service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor'))) + result = service.resolveLink(testModel, followLink('predecessor', {}, true, followLink('successor'))) }); it('should call getLinkDefinition with the correct model and link', () => { @@ -144,7 +144,7 @@ describe('LinkService', () => { }); it('should throw an error', () => { expect(() => { - service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor'))) + service.resolveLink(testModel, followLink('predecessor', {}, true, followLink('successor'))) }).toThrow(); }); }); @@ -160,7 +160,7 @@ describe('LinkService', () => { }); it('should throw an error', () => { expect(() => { - service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor'))) + service.resolveLink(testModel, followLink('predecessor', {}, true, followLink('successor'))) }).toThrow(); }); }); @@ -218,5 +218,52 @@ describe('LinkService', () => { }); }); + describe('when a link is missing', () => { + beforeEach(() => { + testModel = Object.assign(new TestModel(), { + value: 'a test value', + _links: { + self: { + href: 'http://self.link' + }, + predecessor: { + href: 'http://predecessor.link' + } + } + }); + spyOnFunction(decorators, 'getDataServiceFor').and.returnValue(TestDataService); + }); + + describe('resolving the available link', () => { + beforeEach(() => { + spyOnFunction(decorators, 'getLinkDefinition').and.returnValue({ + resourceType: TEST_MODEL, + linkName: 'predecessor', + propertyName: 'predecessor' + }); + result = service.resolveLinks(testModel, followLink('predecessor')); + }); + + it('should return the model with the resolved link', () => { + expect(result.predecessor).toBe('findByHref'); + }); + }); + + describe('resolving the missing link', () => { + beforeEach(() => { + spyOnFunction(decorators, 'getLinkDefinition').and.returnValue({ + resourceType: TEST_MODEL, + linkName: 'successor', + propertyName: 'successor' + }); + result = service.resolveLinks(testModel, followLink('successor')); + }); + + it('should return the model with no resolved link', () => { + expect(result.successor).toBeUndefined(); + }); + }); + }); + }); /* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/cache/builders/link.service.ts b/src/app/core/cache/builders/link.service.ts index c41a5484a1..dc65eab68f 100644 --- a/src/app/core/cache/builders/link.service.ts +++ b/src/app/core/cache/builders/link.service.ts @@ -55,16 +55,19 @@ export class LinkService { parent: this.parentInjector }).get(provider); - const href = model._links[matchingLinkDef.linkName].href; + const link = model._links[matchingLinkDef.linkName]; + if (hasValue(link)) { + const href = link.href; - try { - if (matchingLinkDef.isList) { - model[linkToFollow.name] = service.findAllByHref(href, linkToFollow.findListOptions, ...linkToFollow.linksToFollow); - } else { - model[linkToFollow.name] = service.findByHref(href, ...linkToFollow.linksToFollow); + try { + if (matchingLinkDef.isList) { + model[linkToFollow.name] = service.findAllByHref(href, linkToFollow.findListOptions, ...linkToFollow.linksToFollow); + } else { + model[linkToFollow.name] = service.findByHref(href, ...linkToFollow.linksToFollow); + } + } catch (e) { + throw new Error(`Something went wrong when using @dataService(${matchingLinkDef.resourceType.value}) ${hasValue(service) ? '' : '(undefined) '}to resolve link ${linkToFollow.name} from ${href}`); } - } catch (e) { - throw new Error(`Something went wrong when using @dataService(${matchingLinkDef.resourceType.value}) ${hasValue(service) ? '' : '(undefined) '}to resolve link ${linkToFollow.name} from ${href}`); } } return model; 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 68be319027..2352de6daf 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -96,14 +96,15 @@ 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.statusCode === 204 || - reqEntry.response.statusCode >= 200 && reqEntry.response.statusCode < 300 && hasValue(payload); - const errorMessage = isSuccessful === false ? (reqEntry.response as ErrorResponse).errorMessage : undefined; + const response = reqEntry ? reqEntry.response : undefined; + if (hasValue(response)) { + isSuccessful = response.statusCode === 204 || + response.statusCode >= 200 && response.statusCode < 300 && hasValue(payload); + 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 ); } @@ -113,7 +114,9 @@ export class RemoteDataBuildService { responsePending, isSuccessful, error, - payload + payload, + hasValue(response) ? response.statusCode : undefined + ); }) ); diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index 53894df5f1..d82a1f31fe 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -10,6 +10,7 @@ import { coreSelector } from '../core.selectors'; import { RestRequestMethod } from '../data/rest-request-method'; import { selfLinkFromUuidSelector } from '../index/index.selectors'; import { GenericConstructor } from '../shared/generic-constructor'; +import { getClassForType } from './builders/build-decorators'; import { LinkService } from './builders/link.service'; import { AddPatchObjectCacheAction, @@ -20,7 +21,6 @@ import { import { CacheableObject, ObjectCacheEntry, ObjectCacheState } from './object-cache.reducer'; import { AddToSSBAction } from './server-sync-buffer.actions'; -import { getClassForType } from './builders/build-decorators'; /** * The base selector function to select the object cache in the store @@ -48,7 +48,7 @@ export class ObjectCacheService { constructor( private store: Store, private linkService: LinkService - ) { + ) { } /** diff --git a/src/app/core/cache/server-sync-buffer.effects.spec.ts b/src/app/core/cache/server-sync-buffer.effects.spec.ts index 37ad0e6346..c2aa7b14f9 100644 --- a/src/app/core/cache/server-sync-buffer.effects.spec.ts +++ b/src/app/core/cache/server-sync-buffer.effects.spec.ts @@ -51,6 +51,14 @@ describe('ServerSyncBufferEffects', () => { _links: { self: { href: link } } }); return observableOf(object); + }, + getBySelfLink: (link) => { + const object = Object.assign(new DSpaceObject(), { + _links: { + self: { href: link } + } + }); + return observableOf(object); } } }, diff --git a/src/app/core/cache/server-sync-buffer.effects.ts b/src/app/core/cache/server-sync-buffer.effects.ts index 3a0e801f27..fd398f2971 100644 --- a/src/app/core/cache/server-sync-buffer.effects.ts +++ b/src/app/core/cache/server-sync-buffer.effects.ts @@ -16,13 +16,15 @@ import { Action, createSelector, MemoizedSelector, select, Store } from '@ngrx/s import { ServerSyncBufferEntry, ServerSyncBufferState } from './server-sync-buffer.reducer'; import { combineLatest as observableCombineLatest, of as observableOf } from 'rxjs'; import { RequestService } from '../data/request.service'; -import { PutRequest } from '../data/request.models'; +import { PatchRequest, PutRequest } from '../data/request.models'; import { ObjectCacheService } from './object-cache.service'; import { ApplyPatchObjectCacheAction } from './object-cache.actions'; import { GenericConstructor } from '../shared/generic-constructor'; import { hasValue, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; import { Observable } from 'rxjs/internal/Observable'; import { RestRequestMethod } from '../data/rest-request-method'; +import { ObjectCacheEntry } from './object-cache.reducer'; +import { Operation } from 'fast-json-patch'; @Injectable() export class ServerSyncBufferEffects { @@ -96,17 +98,19 @@ export class ServerSyncBufferEffects { * @returns {Observable} ApplyPatchObjectCacheAction to be dispatched */ private applyPatch(href: string): Observable { - const patchObject = this.objectCache.getObjectBySelfLink(href).pipe(take(1)); + const patchObject = this.objectCache.getBySelfLink(href).pipe(take(1)); return patchObject.pipe( - map((object) => { - const serializedObject = new DSpaceSerializer(object.constructor as GenericConstructor<{}>).serialize(object); - - this.requestService.configure(new PutRequest(this.requestService.generateRequestId(), href, serializedObject)); - - return new ApplyPatchObjectCacheAction(href) + map((entry: ObjectCacheEntry) => { + if (isNotEmpty(entry.patches)) { + const flatPatch: Operation[] = [].concat(...entry.patches.map((patch) => patch.operations)); + if (isNotEmpty(flatPatch)) { + this.requestService.configure(new PatchRequest(this.requestService.generateRequestId(), href, flatPatch)); + } + } + return new ApplyPatchObjectCacheAction(href); }) - ) + ); } constructor(private actions$: Actions, diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 82edaed96b..76b0e3e76c 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -1,24 +1,19 @@ import { CommonModule } from '@angular/common'; import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http'; import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core'; -import { - DynamicFormLayoutService, - DynamicFormService, - DynamicFormValidationService -} from '@ng-dynamic-forms/core'; -import { EffectsModule } from '@ngrx/effects'; +import { DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService } from '@ng-dynamic-forms/core'; +import { EffectsModule } from '@ngrx/effects'; import { StoreModule } from '@ngrx/store'; + import { MyDSpaceGuard } from '../+my-dspace-page/my-dspace.guard'; import { ENV_CONFIG, GLOBAL_CONFIG, GlobalConfig } from '../../config'; - import { isNotEmpty } from '../shared/empty.util'; import { FormBuilderService } from '../shared/form/builder/form-builder.service'; import { FormService } from '../shared/form/form.service'; import { HostWindowService } from '../shared/host-window.service'; import { MenuService } from '../shared/menu/menu.service'; import { EndpointMockingRestService } from '../shared/mocks/dspace-rest-v2/endpoint-mocking-rest.service'; - import { MOCK_RESPONSE_MAP, MockResponseMap, @@ -48,7 +43,6 @@ import { SubmissionUploadsModel } from './config/models/config-submission-upload import { SubmissionDefinitionsConfigService } from './config/submission-definitions-config.service'; import { SubmissionFormsConfigService } from './config/submission-forms-config.service'; import { SubmissionSectionsConfigService } from './config/submission-sections-config.service'; - import { coreEffects } from './core.effects'; import { coreReducers } from './core.reducers'; import { BitstreamFormatDataService } from './data/bitstream-format-data.service'; @@ -103,7 +97,6 @@ import { RegistryService } from './registry/registry.service'; import { RoleService } from './roles/role.service'; import { ApiService } from './services/api.service'; -import { RouteService } from './services/route.service'; import { ServerResponseService } from './services/server-response.service'; import { NativeWindowFactory, NativeWindowService } from './services/window.service'; import { BitstreamFormat } from './shared/bitstream-format.model'; @@ -142,6 +135,10 @@ import { PoolTask } from './tasks/models/pool-task-object.model'; import { TaskObject } from './tasks/models/task-object.model'; import { PoolTaskDataService } from './tasks/pool-task-data.service'; import { TaskResponseParsingService } from './tasks/task-response-parsing.service'; +import { VersionDataService } from './data/version-data.service'; +import { VersionHistoryDataService } from './data/version-history-data.service'; +import { Version } from './shared/version.model'; +import { VersionHistory } from './shared/version-history.model'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -175,7 +172,11 @@ const PROVIDERS = [ SiteDataService, DSOResponseParsingService, { provide: MOCK_RESPONSE_MAP, useValue: mockResponseMap }, - { provide: DSpaceRESTv2Service, useFactory: restServiceFactory, deps: [GLOBAL_CONFIG, MOCK_RESPONSE_MAP, HttpClient]}, + { + provide: DSpaceRESTv2Service, + useFactory: restServiceFactory, + deps: [GLOBAL_CONFIG, MOCK_RESPONSE_MAP, HttpClient] + }, DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService, @@ -211,7 +212,6 @@ const PROVIDERS = [ BrowseItemsResponseParsingService, BrowseService, ConfigResponseParsingService, - RouteService, SubmissionDefinitionsConfigService, SubmissionFormsConfigService, SubmissionRestService, @@ -255,6 +255,8 @@ const PROVIDERS = [ RelationshipTypeService, ExternalSourceService, LookupRelationService, + VersionDataService, + VersionHistoryDataService, LicenseDataService, ItemTypeDataService, // register AuthInterceptor as HttpInterceptor @@ -305,6 +307,8 @@ export const models = ItemType, ExternalSource, ExternalSourceEntry, + Version, + VersionHistory ]; @NgModule({ diff --git a/src/app/core/data/base-response-parsing.service.spec.ts b/src/app/core/data/base-response-parsing.service.spec.ts new file mode 100644 index 0000000000..a1d602dc65 --- /dev/null +++ b/src/app/core/data/base-response-parsing.service.spec.ts @@ -0,0 +1,104 @@ +import { BaseResponseParsingService } from './base-response-parsing.service'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { CacheableObject } from '../cache/object-cache.reducer'; +import { GetRequest, RestRequest } from './request.models'; +import { DSpaceObject } from '../shared/dspace-object.model'; + +/* tslint:disable:max-classes-per-file */ +class TestService extends BaseResponseParsingService { + toCache = true; + + constructor(protected EnvConfig: GlobalConfig, + protected objectCache: ObjectCacheService) { + super(); + } + + // Overwrite methods to make them public for testing + public process(data: any, request: RestRequest): any { + super.process(data, request); + } + + public cache(obj, request: RestRequest, data: any) { + super.cache(obj, request, data); + } +} + +describe('BaseResponseParsingService', () => { + let service: TestService; + let config: GlobalConfig; + let objectCache: ObjectCacheService; + + const requestUUID = 'request-uuid'; + const requestHref = 'request-href'; + const request = new GetRequest(requestUUID, requestHref); + + beforeEach(() => { + config = Object.assign({}); + objectCache = jasmine.createSpyObj('objectCache', { + add: {} + }); + service = new TestService(config, objectCache); + }); + + describe('cache', () => { + let obj: CacheableObject; + + describe('when the object is undefined', () => { + it('should not throw an error', () => { + expect(() => { service.cache(obj, request, {}) }).not.toThrow(); + }); + + it('should not call objectCache add', () => { + service.cache(obj, request, {}); + expect(objectCache.add).not.toHaveBeenCalled(); + }); + }); + + describe('when the object has a self link', () => { + beforeEach(() => { + obj = Object.assign(new DSpaceObject(), { + _links: { + self: { href: 'obj-selflink' } + } + }); + }); + + it('should call objectCache add', () => { + service.cache(obj, request, {}); + expect(objectCache.add).toHaveBeenCalledWith(obj, request.responseMsToLive, request.uuid); + }); + }); + }); + + describe('process', () => { + let data: any; + let result: any; + + describe('when data is valid, but not a real type', () => { + beforeEach(() => { + data = { + type: 'NotARealType', + _links: { + self: { href: 'data-selflink' } + } + }; + }); + + it('should not throw an error', () => { + expect(() => { result = service.process(data, request) }).not.toThrow(); + }); + + it('should return undefined', () => { + result = service.process(data, request); + expect(result).toBeUndefined(); + }); + + it('should not call objectCache add', () => { + result = service.process(data, request); + expect(objectCache.add).not.toHaveBeenCalled(); + }); + }); + }); +}); +/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/data/base-response-parsing.service.ts b/src/app/core/data/base-response-parsing.service.ts index 3615ab4023..efbe838d82 100644 --- a/src/app/core/data/base-response-parsing.service.ts +++ b/src/app/core/data/base-response-parsing.service.ts @@ -57,7 +57,7 @@ export abstract class BaseResponseParsingService { .filter((property) => data._embedded.hasOwnProperty(property)) .forEach((property) => { const parsedObj = this.process(data._embedded[property], request); - if (this.shouldDirectlyAttachEmbeds && isNotEmpty(parsedObj)) { + if (hasValue(object) && this.shouldDirectlyAttachEmbeds && isNotEmpty(parsedObj)) { if (isRestPaginatedList(data._embedded[property])) { object[property] = parsedObj; object[property].page = parsedObj.page.map((obj) => this.retrieveObjectOrUrl(obj)); @@ -117,10 +117,12 @@ export abstract class BaseResponseParsingService { const serializer = new this.serializerConstructor(objConstructor); return serializer.deserialize(obj); } else { + console.warn('cannot deserialize type ' + type); return null; } } else { + console.warn('cannot deserialize type ' + type); return null; } } @@ -142,7 +144,8 @@ export abstract class BaseResponseParsingService { } else { dataJSON = JSON.stringify(data); } - throw new Error(`Can't cache incomplete ${type}: ${JSON.stringify(co)}, parsed from (partial) response: ${dataJSON}`); + console.warn(`Can't cache incomplete ${type}: ${JSON.stringify(co)}, parsed from (partial) response: ${dataJSON}`); + return; } this.objectCache.add(co, hasValue(request.responseMsToLive) ? request.responseMsToLive : this.EnvConfig.cache.msToLive.default, request.uuid); } diff --git a/src/app/core/data/bitstream-data.service.ts b/src/app/core/data/bitstream-data.service.ts index 408dceb56e..c571c7f96c 100644 --- a/src/app/core/data/bitstream-data.service.ts +++ b/src/app/core/data/bitstream-data.service.ts @@ -3,7 +3,7 @@ import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs/internal/Observable'; import { map, switchMap } from 'rxjs/operators'; -import { hasValue } from '../../shared/empty.util'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { dataService } from '../cache/builders/build-decorators'; @@ -72,7 +72,7 @@ export class BitstreamDataService extends DataService { public getThumbnailFor(item: Item): Observable> { return this.bundleService.findByItemAndName(item, 'THUMBNAIL').pipe( switchMap((bundleRD: RemoteData) => { - if (hasValue(bundleRD.payload)) { + if (isNotEmpty(bundleRD.payload)) { return this.findAllByBundle(bundleRD.payload, { elementsPerPage: 1 }).pipe( map((bitstreamRD: RemoteData>) => { if (hasValue(bitstreamRD.payload) && hasValue(bitstreamRD.payload.page)) { @@ -108,7 +108,7 @@ export class BitstreamDataService extends DataService { public getMatchingThumbnail(item: Item, bitstreamInOriginal: Bitstream): Observable> { return this.bundleService.findByItemAndName(item, 'THUMBNAIL').pipe( switchMap((bundleRD: RemoteData) => { - if (hasValue(bundleRD.payload)) { + if (isNotEmpty(bundleRD.payload)) { return this.findAllByBundle(bundleRD.payload, { elementsPerPage: Number.MAX_SAFE_INTEGER }).pipe( map((bitstreamRD: RemoteData>) => { if (hasValue(bitstreamRD.payload) && hasValue(bitstreamRD.payload.page)) { 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.spec.ts b/src/app/core/data/data.service.spec.ts index 347dfa83a4..c370be2b9e 100644 --- a/src/app/core/data/data.service.spec.ts +++ b/src/app/core/data/data.service.spec.ts @@ -1,21 +1,25 @@ -import { DataService } from './data.service'; -import { RequestService } from './request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { CoreState } from '../core.reducers'; +import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { compare, Operation } from 'fast-json-patch'; import { Observable, of as observableOf } from 'rxjs'; -import { FindListOptions } from './request.models'; +import * as uuidv4 from 'uuid/v4'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { SortDirection, SortOptions } from '../cache/models/sort-options.model'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { compare, Operation } from 'fast-json-patch'; +import { CoreState } from '../core.reducers'; +import { Collection } from '../shared/collection.model'; import { DSpaceObject } from '../shared/dspace-object.model'; -import { ChangeAnalyzer } from './change-analyzer'; -import { HttpClient } from '@angular/common/http'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { Item } from '../shared/item.model'; -import * as uuidv4 from 'uuid/v4'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils'; +import { ChangeAnalyzer } from './change-analyzer'; +import { DataService } from './data.service'; +import { FindListOptions, PatchRequest } from './request.models'; +import { RequestService } from './request.service'; +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; const endpoint = 'https://rest.api/core'; @@ -40,6 +44,7 @@ class TestService extends DataService { return observableOf(endpoint); } } + class DummyChangeAnalyzer implements ChangeAnalyzer { diff(object1: Item, object2: Item): Operation[] { return compare((object1 as any).metadata, (object2 as any).metadata); @@ -50,8 +55,8 @@ class DummyChangeAnalyzer implements ChangeAnalyzer { describe('DataService', () => { let service: TestService; let options: FindListOptions; - const requestService = {generateRequestId: () => uuidv4()} as RequestService; - const halService = {} as HALEndpointService; + const requestService = getMockRequestService(); + const halService = new HALEndpointServiceStub('url') as any; const rdbService = {} as RemoteDataBuildService; const notificationsService = {} as NotificationsService; const http = {} as HttpClient; @@ -144,21 +149,161 @@ describe('DataService', () => { (service as any).getFindAllHref(options).subscribe((value) => { expect(value).toBe(expected); }); - }) - }); - describe('patch', () => { - let operations; - let selfLink; - - beforeEach(() => { - operations = [{ op: 'replace', path: '/metadata/dc.title', value: 'random string' } as Operation]; - selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; - spyOn(objectCache, 'addPatch'); }); - it('should call addPatch on the object cache with the right parameters', () => { - service.patch(selfLink, operations); - expect(objectCache.addPatch).toHaveBeenCalledWith(selfLink, operations); + it('should include single linksToFollow as embed', () => { + const mockFollowLinkConfig: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'bundles' as any, + }); + const expected = `${endpoint}?embed=bundles`; + + (service as any).getFindAllHref({}, null, mockFollowLinkConfig).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include multiple linksToFollow as embed', () => { + const mockFollowLinkConfig: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'bundles' as any, + }); + const mockFollowLinkConfig2: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'owningCollection' as any, + }); + const mockFollowLinkConfig3: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'templateItemOf' as any, + }); + const expected = `${endpoint}?embed=bundles&embed=owningCollection&embed=templateItemOf`; + + (service as any).getFindAllHref({}, null, mockFollowLinkConfig, mockFollowLinkConfig2, mockFollowLinkConfig3).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should not include linksToFollow with shouldEmbed = false', () => { + const mockFollowLinkConfig: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'bundles' as any, + shouldEmbed: false, + }); + const mockFollowLinkConfig2: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'owningCollection' as any, + shouldEmbed: false, + }); + const mockFollowLinkConfig3: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'templateItemOf' as any, + }); + const expected = `${endpoint}?embed=templateItemOf`; + + (service as any).getFindAllHref({}, null, mockFollowLinkConfig, mockFollowLinkConfig2, mockFollowLinkConfig3).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + + it('should include nested linksToFollow 3lvl', () => { + const mockFollowLinkConfig3: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'relationships' as any, + }); + const mockFollowLinkConfig2: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'itemtemplate' as any, + linksToFollow: mockFollowLinkConfig3, + }); + const mockFollowLinkConfig: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'owningCollection' as any, + linksToFollow: mockFollowLinkConfig2, + }); + const expected = `${endpoint}?embed=owningCollection/itemtemplate/relationships`; + + (service as any).getFindAllHref({}, null, mockFollowLinkConfig).subscribe((value) => { + expect(value).toBe(expected); + }); + }); + }); + + describe('getIDHref', () => { + const endpointMock = 'https://dspace7-internal.atmire.com/server/api/core/items'; + const resourceIdMock = '003c99b4-d4fe-44b0-a945-e12182a7ca89'; + + it('should return endpoint', () => { + const result = (service as any).getIDHref(endpointMock, resourceIdMock); + expect(result).toEqual(endpointMock + '/' + resourceIdMock); + }); + + it('should include single linksToFollow as embed', () => { + const mockFollowLinkConfig: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'bundles' as any, + }); + const expected = `${endpointMock}/${resourceIdMock}?embed=bundles`; + const result = (service as any).getIDHref(endpointMock, resourceIdMock, mockFollowLinkConfig); + expect(result).toEqual(expected); + }); + + it('should include multiple linksToFollow as embed', () => { + const mockFollowLinkConfig: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'bundles' as any, + }); + const mockFollowLinkConfig2: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'owningCollection' as any, + }); + const mockFollowLinkConfig3: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'templateItemOf' as any, + }); + const expected = `${endpointMock}/${resourceIdMock}?embed=bundles&embed=owningCollection&embed=templateItemOf`; + const result = (service as any).getIDHref(endpointMock, resourceIdMock, mockFollowLinkConfig, mockFollowLinkConfig2, mockFollowLinkConfig3); + expect(result).toEqual(expected); + }); + + it('should not include linksToFollow with shouldEmbed = false', () => { + const mockFollowLinkConfig: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'bundles' as any, + shouldEmbed: false, + }); + const mockFollowLinkConfig2: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'owningCollection' as any, + shouldEmbed: false, + }); + const mockFollowLinkConfig3: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'templateItemOf' as any, + }); + const expected = `${endpointMock}/${resourceIdMock}?embed=templateItemOf`; + const result = (service as any).getIDHref(endpointMock, resourceIdMock, mockFollowLinkConfig, mockFollowLinkConfig2, mockFollowLinkConfig3); + expect(result).toEqual(expected); + }); + + it('should include nested linksToFollow 3lvl', () => { + const mockFollowLinkConfig3: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'relationships' as any, + }); + const mockFollowLinkConfig2: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'itemtemplate' as any, + linksToFollow: mockFollowLinkConfig3, + }); + const mockFollowLinkConfig: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'owningCollection' as any, + linksToFollow: mockFollowLinkConfig2, + }); + const expected = `${endpointMock}/${resourceIdMock}?embed=owningCollection/itemtemplate/relationships`; + const result = (service as any).getIDHref(endpointMock, resourceIdMock, mockFollowLinkConfig); + expect(result).toEqual(expected); + }); + }); + + describe('patch', () => { + const dso = { + uuid: 'dso-uuid' + }; + const operations = [ + Object.assign({ + op: 'move', + from: '/1', + path: '/5' + }) as Operation + ]; + + beforeEach(() => { + service.patch(dso, operations); + }); + + it('should configure a PatchRequest', () => { + expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(PatchRequest)); }); }); diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index ea6d1e0d09..f14f6e30c8 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -44,7 +44,7 @@ import { FindByIDRequest, FindListOptions, FindListRequest, - GetRequest + GetRequest, PatchRequest } from './request.models'; import { RequestEntry } from './request.reducer'; import { RequestService } from './request.service'; @@ -83,14 +83,15 @@ export abstract class DataService { * @param linkPath The link path for the object * @return {Observable} * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ - protected getFindAllHref(options: FindListOptions = {}, linkPath?: string): Observable { + protected getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: Array>): Observable { let result$: Observable; const args = []; result$ = this.getBrowseEndpoint(options, linkPath).pipe(distinctUntilChanged()); - return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args))); + return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); } /** @@ -100,8 +101,9 @@ export abstract class DataService { * @param options The [[FindListOptions]] object * @return {Observable} * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ - protected getSearchByHref(searchMethod: string, options: FindListOptions = {}): Observable { + protected getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable { let result$: Observable; const args = []; @@ -113,7 +115,7 @@ export abstract class DataService { }) } - return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args))); + return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); } /** @@ -124,9 +126,9 @@ export abstract class DataService { * @param extraArgs Array with additional params to combine with query string * @return {Observable} * Return an observable that emits created HREF + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ - protected buildHrefFromFindOptions(href: string, options: FindListOptions, extraArgs: string[] = []): string { - + protected buildHrefFromFindOptions(href: string, options: FindListOptions, extraArgs: string[] = [], ...linksToFollow: Array>): string { let args = [...extraArgs]; if (hasValue(options.currentPage) && typeof options.currentPage === 'number') { @@ -142,6 +144,7 @@ export abstract class DataService { if (hasValue(options.startsWith)) { args = [...args, `startsWith=${options.startsWith}`]; } + args = this.addEmbedParams(args, ...linksToFollow); if (isNotEmpty(args)) { return new URLCombiner(href, `?${args.join('&')}`).toString(); } else { @@ -149,10 +152,48 @@ export abstract class DataService { } } + /** + * Adds the embed options to the link for the request + * @param args params for the query string + * @param linksToFollow links we want to embed in query string if shouldEmbed is true + */ + protected addEmbedParams(args: string[], ...linksToFollow: Array>) { + linksToFollow.forEach((linkToFollow: FollowLinkConfig) => { + if (linkToFollow !== undefined && linkToFollow.shouldEmbed) { + const embedString = 'embed=' + String(linkToFollow.name); + const embedWithNestedString = this.addNestedEmbeds(embedString, ...linkToFollow.linksToFollow); + args = [...args, embedWithNestedString]; + } + }); + return args; + } + + /** + * Add the nested followLinks to the embed param, recursively, separated by a / + * @param embedString embedString so far (recursive) + * @param linksToFollow links we want to embed in query string if shouldEmbed is true + */ + protected addNestedEmbeds(embedString: string, ...linksToFollow: Array>): string { + let nestEmbed = embedString; + linksToFollow.forEach((linkToFollow: FollowLinkConfig) => { + if (linkToFollow !== undefined && linkToFollow.shouldEmbed) { + nestEmbed = nestEmbed + '/' + String(linkToFollow.name); + if (linkToFollow.linksToFollow !== undefined) { + nestEmbed = this.addNestedEmbeds(nestEmbed, ...linkToFollow.linksToFollow); + } + } + }); + return nestEmbed; + } + /** * 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 +203,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>) { @@ -179,12 +221,13 @@ export abstract class DataService { } /** - * Create the HREF for a specific object based on its identifier + * Create the HREF for a specific object based on its identifier; with possible embed query params based on linksToFollow * @param endpoint The base endpoint for the type of object * @param resourceID The identifier for the object + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ - getIDHref(endpoint, resourceID): string { - return `${endpoint}/${resourceID}`; + getIDHref(endpoint, resourceID, ...linksToFollow: Array>): string { + return this.buildHrefFromFindOptions(endpoint + '/' + resourceID, {}, [], ...linksToFollow); } /** @@ -194,9 +237,8 @@ export abstract class DataService { * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ findById(id: string, ...linksToFollow: Array>): Observable> { - const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( - map((endpoint: string) => this.getIDHref(endpoint, encodeURIComponent(id)))); + map((endpoint: string) => this.getIDHref(endpoint, encodeURIComponent(id), ...linksToFollow))); hrefObs.pipe( find((href: string) => hasValue(href))) @@ -218,7 +260,7 @@ export abstract class DataService { * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ findByHref(href: string, ...linksToFollow: Array>): Observable> { - const requestHref = this.buildHrefFromFindOptions(href, {}, []); + const requestHref = this.buildHrefFromFindOptions(href, {}, [], ...linksToFollow); const request = new GetRequest(this.requestService.generateRequestId(), requestHref); if (hasValue(this.responseMsToLive)) { request.responseMsToLive = this.responseMsToLive; @@ -231,10 +273,11 @@ 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>> { - const requestHref = this.buildHrefFromFindOptions(href, findListOptions, []); + const requestHref = this.buildHrefFromFindOptions(href, findListOptions, [], ...linksToFollow); const request = new GetRequest(this.requestService.generateRequestId(), requestHref); if (hasValue(this.responseMsToLive)) { request.responseMsToLive = this.responseMsToLive; @@ -259,12 +302,13 @@ 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 */ - protected searchBy(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { + searchBy(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { - const hrefObs = this.getSearchByHref(searchMethod, options); + const hrefObs = this.getSearchByHref(searchMethod, options, ...linksToFollow); return hrefObs.pipe( find((href: string) => hasValue(href)), @@ -285,12 +329,28 @@ export abstract class DataService { } /** - * Add a new patch to the object cache to a specified object - * @param {string} href The selflink of the object that will be patched + * Send a patch request for a specified object + * @param {T} dso The object to send a patch request for * @param {Operation[]} operations The patch operations to be performed */ - patch(href: string, operations: Operation[]) { - this.objectCache.addPatch(href, operations); + patch(dso: T, operations: Operation[]): Observable { + const requestId = this.requestService.generateRequestId(); + + const hrefObs = this.halService.getEndpoint(this.linkPath).pipe( + map((endpoint: string) => this.getIDHref(endpoint, dso.uuid))); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new PatchRequest(requestId, href, operations); + this.requestService.configure(request); + }) + ).subscribe(); + + return this.requestService.getByUUID(requestId).pipe( + find((request: RequestEntry) => request.completed), + map((request: RequestEntry) => request.response) + ); } /** @@ -365,18 +425,60 @@ export abstract class DataService { ) } + /** + * Create a new DSpaceObject on the server, and store the response + * in the object cache, returns observable of the response to determine success + * + * @param {DSpaceObject} dso + * The object to create + */ + tryToCreate(dso: T): Observable { + const requestId = this.requestService.generateRequestId(); + const endpoint$ = this.halService.getEndpoint(this.linkPath).pipe( + isNotEmptyOperator(), + distinctUntilChanged(), + ); + + const serializedDso = new DSpaceSerializer(getClassForType((dso as any).type)).serialize(dso); + + const request$ = endpoint$.pipe( + take(1), + map((endpoint: string) => new CreateRequest(requestId, endpoint, JSON.stringify(serializedDso))) + ); + + // Execute the post request + request$.pipe( + configureRequest(this.requestService) + ).subscribe(); + + return this.fetchResponse(requestId); + } + + /** + * Gets the restResponse from the requestService + * @param requestId + */ + protected fetchResponse(requestId: string): Observable { + return this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry(), + map((response: RestResponse) => { + return response; + }) + ); + } + /** * 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 +490,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/dso-change-analyzer.service.ts b/src/app/core/data/dso-change-analyzer.service.ts index dba8395bc5..af0b95234b 100644 --- a/src/app/core/data/dso-change-analyzer.service.ts +++ b/src/app/core/data/dso-change-analyzer.service.ts @@ -3,6 +3,8 @@ import { compare } from 'fast-json-patch'; import { ChangeAnalyzer } from './change-analyzer'; import { Injectable } from '@angular/core'; import { DSpaceObject } from '../shared/dspace-object.model'; +import { MetadataMap } from '../shared/metadata.models'; +import { cloneDeep } from 'lodash'; /** * A class to determine what differs between two @@ -21,6 +23,21 @@ export class DSOChangeAnalyzer implements ChangeAnalyzer * The second object to compare */ diff(object1: DSpaceObject, object2: DSpaceObject): Operation[] { - return compare(object1.metadata, object2.metadata).map((operation: Operation) => Object.assign({}, operation, { path: '/metadata' + operation.path })); + return compare(this.filterUUIDsFromMetadata(object1.metadata), this.filterUUIDsFromMetadata(object2.metadata)) + .map((operation: Operation) => Object.assign({}, operation, { path: '/metadata' + operation.path })); + } + + /** + * Filter the UUIDs out of a MetadataMap + * @param metadata + */ + filterUUIDsFromMetadata(metadata: MetadataMap): MetadataMap { + const result = cloneDeep(metadata); + for (const key of Object.keys(result)) { + for (const metadataValue of result[key]) { + metadataValue.uuid = undefined; + } + } + return result; } } diff --git a/src/app/core/data/dso-redirect-data.service.spec.ts b/src/app/core/data/dso-redirect-data.service.spec.ts index 25a148d92b..4ef5bcb8b4 100644 --- a/src/app/core/data/dso-redirect-data.service.spec.ts +++ b/src/app/core/data/dso-redirect-data.service.spec.ts @@ -1,15 +1,18 @@ +import { HttpClient } from '@angular/common/http'; +import { Store } from '@ngrx/store'; import { cold, getTestScheduler } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { CoreState } from '../core.reducers'; +import { Collection } from '../shared/collection.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { Item } from '../shared/item.model'; +import { DsoRedirectDataService } from './dso-redirect-data.service'; import { FindByIDRequest, IdentifierType } from './request.models'; import { RequestService } from './request.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { DsoRedirectDataService } from './dso-redirect-data.service'; -import { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; describe('DsoRedirectDataService', () => { let scheduler: TestScheduler; @@ -148,5 +151,71 @@ describe('DsoRedirectDataService', () => { scheduler.flush(); expect(router.navigate).toHaveBeenCalledWith(['communities/' + remoteData.payload.uuid]); }); - }) + }); + + describe('getIDHref', () => { + it('should return endpoint', () => { + const result = (service as any).getIDHref(pidLink, dsoUUID); + expect(result).toEqual(requestUUIDURL); + }); + + it('should include single linksToFollow as embed', () => { + const mockFollowLinkConfig: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'bundles' as any, + }); + const expected = `${requestUUIDURL}&embed=bundles`; + const result = (service as any).getIDHref(pidLink, dsoUUID, mockFollowLinkConfig); + expect(result).toEqual(expected); + }); + + it('should include multiple linksToFollow as embed', () => { + const mockFollowLinkConfig: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'bundles' as any, + }); + const mockFollowLinkConfig2: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'owningCollection' as any, + }); + const mockFollowLinkConfig3: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'templateItemOf' as any, + }); + const expected = `${requestUUIDURL}&embed=bundles&embed=owningCollection&embed=templateItemOf`; + const result = (service as any).getIDHref(pidLink, dsoUUID, mockFollowLinkConfig, mockFollowLinkConfig2, mockFollowLinkConfig3); + expect(result).toEqual(expected); + }); + + it('should not include linksToFollow with shouldEmbed = false', () => { + const mockFollowLinkConfig: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'bundles' as any, + shouldEmbed: false, + }); + const mockFollowLinkConfig2: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'owningCollection' as any, + shouldEmbed: false, + }); + const mockFollowLinkConfig3: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'templateItemOf' as any, + }); + const expected = `${requestUUIDURL}&embed=templateItemOf`; + const result = (service as any).getIDHref(pidLink, dsoUUID, mockFollowLinkConfig, mockFollowLinkConfig2, mockFollowLinkConfig3); + expect(result).toEqual(expected); + }); + + it('should include nested linksToFollow 3lvl', () => { + const mockFollowLinkConfig3: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'relationships' as any, + }); + const mockFollowLinkConfig2: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'itemtemplate' as any, + linksToFollow: mockFollowLinkConfig3, + }); + const mockFollowLinkConfig: FollowLinkConfig = Object.assign(new FollowLinkConfig(), { + name: 'owningCollection' as any, + linksToFollow: mockFollowLinkConfig2, + }); + const expected = `${requestUUIDURL}&embed=owningCollection/itemtemplate/relationships`; + const result = (service as any).getIDHref(pidLink, dsoUUID, mockFollowLinkConfig); + expect(result).toEqual(expected); + }); + }); + }); diff --git a/src/app/core/data/dso-redirect-data.service.ts b/src/app/core/data/dso-redirect-data.service.ts index 232fde65d0..87259a4279 100644 --- a/src/app/core/data/dso-redirect-data.service.ts +++ b/src/app/core/data/dso-redirect-data.service.ts @@ -6,6 +6,7 @@ import { Observable } from 'rxjs'; import { take, tap } from 'rxjs/operators'; import { hasValue } from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { CoreState } from '../core.reducers'; @@ -45,10 +46,11 @@ export class DsoRedirectDataService extends DataService { } } - getIDHref(endpoint, resourceID): string { + getIDHref(endpoint, resourceID, ...linksToFollow: Array>): string { // Supporting both identifier (pid) and uuid (dso) endpoints - return endpoint.replace(/\{\?id\}/, `?id=${resourceID}`) - .replace(/\{\?uuid\}/, `?uuid=${resourceID}`); + return this.buildHrefFromFindOptions( endpoint.replace(/\{\?id\}/, `?id=${resourceID}`) + .replace(/\{\?uuid\}/, `?uuid=${resourceID}`), + {}, [], ...linksToFollow); } findByIdAndIDType(id: string, identifierType = IdentifierType.UUID): Observable> { diff --git a/src/app/core/data/dspace-object-data.service.ts b/src/app/core/data/dspace-object-data.service.ts index 38e9f8d888..61cc98281e 100644 --- a/src/app/core/data/dspace-object-data.service.ts +++ b/src/app/core/data/dspace-object-data.service.ts @@ -3,6 +3,7 @@ import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; @@ -31,8 +32,9 @@ class DataServiceImpl extends DataService { super(); } - getIDHref(endpoint, resourceID): string { - return endpoint.replace(/\{\?uuid\}/, `?uuid=${resourceID}`); + getIDHref(endpoint, resourceID, ...linksToFollow: Array>): string { + return this.buildHrefFromFindOptions( endpoint.replace(/\{\?uuid\}/, `?uuid=${resourceID}`), + {}, [], ...linksToFollow); } } diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index b9fb36e6d8..a23eb27f4a 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -20,7 +20,7 @@ import { ITEM } from '../shared/item.resource-type'; import { configureRequest, filterSuccessfulResponses, - getRequestFromRequestHref, + getRequestFromRequestHref, getRequestFromRequestUUID, getResponseFromEntry } from '../shared/operators'; import { URLCombiner } from '../url-combiner/url-combiner'; @@ -180,14 +180,17 @@ export class ItemDataService extends DataService { const patchOperation = [{ op: 'replace', path: '/withdrawn', value: withdrawn }]; + this.requestService.removeByHrefSubstring('/discover'); + return this.getItemWithdrawEndpoint(itemId).pipe( distinctUntilChanged(), map((endpointURL: string) => new PatchRequest(this.requestService.generateRequestId(), endpointURL, patchOperation) ), configureRequest(this.requestService), - map((request: RestRequest) => request.href), - getRequestFromRequestHref(this.requestService), + map((request: RestRequest) => request.uuid), + getRequestFromRequestUUID(this.requestService), + filter((requestEntry: RequestEntry) => requestEntry.completed), map((requestEntry: RequestEntry) => requestEntry.response) ); } @@ -201,14 +204,17 @@ export class ItemDataService extends DataService { const patchOperation = [{ op: 'replace', path: '/discoverable', value: discoverable }]; + this.requestService.removeByHrefSubstring('/discover'); + return this.getItemDiscoverableEndpoint(itemId).pipe( distinctUntilChanged(), map((endpointURL: string) => new PatchRequest(this.requestService.generateRequestId(), endpointURL, patchOperation) ), configureRequest(this.requestService), - map((request: RestRequest) => request.href), - getRequestFromRequestHref(this.requestService), + map((request: RestRequest) => request.uuid), + getRequestFromRequestUUID(this.requestService), + filter((requestEntry: RequestEntry) => requestEntry.completed), map((requestEntry: RequestEntry) => requestEntry.response) ); } 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/data/request.models.ts b/src/app/core/data/request.models.ts index fe992146d8..0655333502 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -119,6 +119,8 @@ export class HeadRequest extends RestRequest { } export class PatchRequest extends RestRequest { + public responseMsToLive = 60 * 15 * 1000; + constructor( public uuid: string, public href: string, @@ -228,6 +230,8 @@ export class AuthPostRequest extends PostRequest { } export class AuthGetRequest extends GetRequest { + forceBypassCache = true; + constructor(uuid: string, href: string, public options?: HttpOptions) { super(uuid, href, null, options); } diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts index 1101c851ac..810b0721ae 100644 --- a/src/app/core/data/request.service.ts +++ b/src/app/core/data/request.service.ts @@ -3,7 +3,7 @@ import { HttpHeaders } from '@angular/common/http'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; import { Observable, race as observableRace } from 'rxjs'; -import { filter, map, mergeMap, switchMap, take } from 'rxjs/operators'; +import { filter, map, mergeMap, take } from 'rxjs/operators'; import { cloneDeep, remove } from 'lodash'; import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; import { CacheableObject } from '../cache/object-cache.reducer'; diff --git a/src/app/core/data/version-data.service.ts b/src/app/core/data/version-data.service.ts new file mode 100644 index 0000000000..80fb4f5b80 --- /dev/null +++ b/src/app/core/data/version-data.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@angular/core'; +import { DataService } from './data.service'; +import { Version } from '../shared/version.model'; +import { RequestService } from './request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { FindListOptions } from './request.models'; +import { Observable } from 'rxjs/internal/Observable'; +import { dataService } from '../cache/builders/build-decorators'; +import { VERSION } from '../shared/version.resource-type'; + +/** + * Service responsible for handling requests related to the Version object + */ +@Injectable() +@dataService(VERSION) +export class VersionDataService extends DataService { + protected linkPath = 'versions'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + super(); + } + + /** + * Get the endpoint for browsing versions + */ + getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable { + return this.halService.getEndpoint(this.linkPath); + } +} diff --git a/src/app/core/data/version-history-data.service.spec.ts b/src/app/core/data/version-history-data.service.spec.ts new file mode 100644 index 0000000000..6728df71f1 --- /dev/null +++ b/src/app/core/data/version-history-data.service.spec.ts @@ -0,0 +1,54 @@ +import { RequestService } from './request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { VersionHistoryDataService } from './version-history-data.service'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { GetRequest } from './request.models'; + +const url = 'fake-url'; + +describe('VersionHistoryDataService', () => { + let service: VersionHistoryDataService; + + let requestService: RequestService; + let notificationsService: any; + let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: any; + + beforeEach(() => { + createService(); + }); + + describe('getVersions', () => { + let result; + + beforeEach(() => { + result = service.getVersions('1'); + }); + + it('should configure a GET request', () => { + expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(GetRequest)); + }); + }); + + /** + * Create a VersionHistoryDataService used for testing + * @param requestEntry$ Supply a requestEntry to be returned by the REST API (optional) + */ + function createService(requestEntry$?) { + requestService = getMockRequestService(requestEntry$); + rdbService = jasmine.createSpyObj('rdbService', { + buildList: jasmine.createSpy('buildList') + }); + objectCache = jasmine.createSpyObj('objectCache', { + remove: jasmine.createSpy('remove') + }); + halService = new HALEndpointServiceStub(url); + notificationsService = new NotificationsServiceStub(); + + service = new VersionHistoryDataService(requestService, rdbService, null, objectCache, halService, notificationsService, null, null); + } +}); diff --git a/src/app/core/data/version-history-data.service.ts b/src/app/core/data/version-history-data.service.ts new file mode 100644 index 0000000000..b4107d629d --- /dev/null +++ b/src/app/core/data/version-history-data.service.ts @@ -0,0 +1,81 @@ +import { DataService } from './data.service'; +import { VersionHistory } from '../shared/version-history.model'; +import { Injectable } from '@angular/core'; +import { RequestService } from './request.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { Store } from '@ngrx/store'; +import { CoreState } from '../core.reducers'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { FindListOptions, GetRequest } from './request.models'; +import { Observable } from 'rxjs/internal/Observable'; +import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; +import { RemoteData } from './remote-data'; +import { PaginatedList } from './paginated-list'; +import { Version } from '../shared/version.model'; +import { map, switchMap, take } from 'rxjs/operators'; +import { dataService } from '../cache/builders/build-decorators'; +import { VERSION_HISTORY } from '../shared/version-history.resource-type'; +import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; + +/** + * Service responsible for handling requests related to the VersionHistory object + */ +@Injectable() +@dataService(VERSION_HISTORY) +export class VersionHistoryDataService extends DataService { + protected linkPath = 'versionhistories'; + protected versionsEndpoint = 'versions'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + super(); + } + + /** + * Get the endpoint for browsing versions + */ + getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable { + return this.halService.getEndpoint(this.linkPath); + } + + /** + * Get the versions endpoint for a version history + * @param versionHistoryId + */ + getVersionsEndpoint(versionHistoryId: string): Observable { + return this.getBrowseEndpoint().pipe( + switchMap((href: string) => this.halService.getEndpoint(this.versionsEndpoint, `${href}/${versionHistoryId}`)) + ); + } + + /** + * Get a version history's versions using paginated search options + * @param versionHistoryId The version history's ID + * @param searchOptions The search options to use + * @param linksToFollow HAL Links to follow on the Versions + */ + getVersions(versionHistoryId: string, searchOptions?: PaginatedSearchOptions, ...linksToFollow: Array>): Observable>> { + const hrefObs = this.getVersionsEndpoint(versionHistoryId).pipe( + map((href) => searchOptions ? searchOptions.toRestUrl(href) : href) + ); + hrefObs.pipe( + take(1) + ).subscribe((href) => { + const request = new GetRequest(this.requestService.generateRequestId(), href); + this.requestService.configure(request); + }); + + return this.rdbService.buildList(hrefObs, ...linksToFollow); + } +} diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts index 91756d412c..6eb144580c 100644 --- a/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts +++ b/src/app/core/dspace-rest-v2/dspace-rest-v2.service.ts @@ -90,6 +90,14 @@ export class DSpaceRESTv2Service { requestOptions.headers = options.headers; } + if (options && options.params) { + requestOptions.params = options.params; + } + + if (options && options.withCredentials) { + requestOptions.withCredentials = options.withCredentials; + } + if (!requestOptions.headers.has('Content-Type')) { // Because HttpHeaders is immutable, the set method returns a new object instead of updating the existing headers requestOptions.headers = requestOptions.headers.set('Content-Type', DEFAULT_CONTENT_TYPE); diff --git a/src/app/core/eperson/eperson-data.service.spec.ts b/src/app/core/eperson/eperson-data.service.spec.ts new file mode 100644 index 0000000000..1831386321 --- /dev/null +++ b/src/app/core/eperson/eperson-data.service.spec.ts @@ -0,0 +1,304 @@ +import { CommonModule } from '@angular/common'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { async, TestBed } from '@angular/core/testing'; +import { Store, StoreModule } from '@ngrx/store'; +import { compare, Operation } from 'fast-json-patch'; +import { getTestScheduler } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { TestScheduler } from 'rxjs/testing'; +import { + EPeopleRegistryCancelEPersonAction, + EPeopleRegistryEditEPersonAction +} from '../../+admin/admin-access-control/epeople-registry/epeople-registry.actions'; +import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/mock-remote-data-build.service'; +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader'; +import { EPersonMock, EPersonMock2 } from '../../shared/testing/eperson-mock'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils'; +import { SearchParam } from '../cache/models/search-param.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { CoreState } from '../core.reducers'; +import { ChangeAnalyzer } from '../data/change-analyzer'; +import { PaginatedList } from '../data/paginated-list'; +import { RemoteData } from '../data/remote-data'; +import { DeleteByIDRequest, FindListOptions, PatchRequest } from '../data/request.models'; +import { RequestEntry } from '../data/request.reducer'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { Item } from '../shared/item.model'; +import { PageInfo } from '../shared/page-info.model'; +import { EPersonDataService } from './eperson-data.service'; +import { EPerson } from './models/eperson.model'; + +describe('EPersonDataService', () => { + let service: EPersonDataService; + let store: Store; + let requestService: RequestService; + let scheduler: TestScheduler; + + const epeople = [EPersonMock, EPersonMock2]; + + const restEndpointURL = 'https://dspace.4science.it/dspace-spring-rest/api/eperson'; + const epersonsEndpoint = `${restEndpointURL}/epersons`; + let halService: any = new HALEndpointServiceStub(restEndpointURL); + const epeople$ = createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [epeople])); + const rdbService = getMockRemoteDataBuildServiceHrefMap(undefined, { 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons': epeople$ }); + const objectCache = Object.assign({ + /* tslint:disable:no-empty */ + remove: () => { + }, + hasBySelfLinkObservable: () => observableOf(false) + /* tslint:enable:no-empty */ + }) as ObjectCacheService; + + TestBed.configureTestingModule({ + imports: [ + CommonModule, + StoreModule.forRoot({}), + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }), + ], + declarations: [], + providers: [], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }); + + const getRequestEntry$ = (successful: boolean) => { + return observableOf({ + completed: true, + response: { isSuccessful: successful, payload: epeople } as any + } as RequestEntry) + }; + + function initTestService() { + return new EPersonDataService( + requestService, + rdbService, + store, + null, + halService, + null, + null, + new DummyChangeAnalyzer() as any + ); + } + + beforeEach(() => { + requestService = getMockRequestService(getRequestEntry$(true)); + store = new Store(undefined, undefined, undefined); + service = initTestService(); + spyOn(store, 'dispatch'); + }); + + describe('searchByScope', () => { + beforeEach(() => { + spyOn(service, 'searchBy'); + }); + + it('search by default scope (byMetadata) and no query', () => { + service.searchByScope(null, ''); + const options = Object.assign(new FindListOptions(), { + searchParams: [Object.assign(new SearchParam('query', ''))] + }); + expect(service.searchBy).toHaveBeenCalledWith('byMetadata', options); + }); + + it('search metadata scope and no query', () => { + service.searchByScope('metadata', ''); + const options = Object.assign(new FindListOptions(), { + searchParams: [Object.assign(new SearchParam('query', ''))] + }); + expect(service.searchBy).toHaveBeenCalledWith('byMetadata', options); + }); + + it('search metadata scope and with query', () => { + service.searchByScope('metadata', 'test'); + const options = Object.assign(new FindListOptions(), { + searchParams: [Object.assign(new SearchParam('query', 'test'))] + }); + expect(service.searchBy).toHaveBeenCalledWith('byMetadata', options); + }); + + it('search email scope and no query', () => { + service.searchByScope('email', ''); + const options = Object.assign(new FindListOptions(), { + searchParams: [Object.assign(new SearchParam('email', ''))] + }); + expect(service.searchBy).toHaveBeenCalledWith('byEmail', options); + }); + }); + + describe('updateEPerson', () => { + beforeEach(() => { + spyOn(service, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(EPersonMock)); + }); + + describe('change Email', () => { + const newEmail = 'changedemail@test.com'; + beforeEach(() => { + const changedEPerson = Object.assign(new EPerson(), { + id: EPersonMock.id, + metadata: EPersonMock.metadata, + email: newEmail, + canLogIn: EPersonMock.canLogIn, + requireCertificate: EPersonMock.requireCertificate, + _links: EPersonMock._links, + }); + service.updateEPerson(changedEPerson).subscribe(); + }); + it('should send PatchRequest with replace email operation', () => { + const operations = [{ op: 'replace', path: '/email', value: newEmail }]; + const expected = new PatchRequest(requestService.generateRequestId(), epersonsEndpoint + '/' + EPersonMock.uuid, operations); + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + }); + + describe('change certificate', () => { + beforeEach(() => { + const changedEPerson = Object.assign(new EPerson(), { + id: EPersonMock.id, + metadata: EPersonMock.metadata, + email: EPersonMock.email, + canLogIn: EPersonMock.canLogIn, + requireCertificate: !EPersonMock.requireCertificate, + _links: EPersonMock._links, + }); + service.updateEPerson(changedEPerson).subscribe(); + }); + it('should send PatchRequest with replace certificate operation', () => { + const operations = [{ op: 'replace', path: '/certificate', value: !EPersonMock.requireCertificate }]; + const expected = new PatchRequest(requestService.generateRequestId(), epersonsEndpoint + '/' + EPersonMock.uuid, operations); + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + }); + + describe('change canLogin', () => { + beforeEach(() => { + const changedEPerson = Object.assign(new EPerson(), { + id: EPersonMock.id, + metadata: EPersonMock.metadata, + email: EPersonMock.email, + canLogIn: !EPersonMock.canLogIn, + requireCertificate: EPersonMock.requireCertificate, + _links: EPersonMock._links, + }); + service.updateEPerson(changedEPerson).subscribe(); + }); + it('should send PatchRequest with replace canLogIn operation', () => { + const operations = [{ op: 'replace', path: '/canLogIn', value: !EPersonMock.canLogIn }]; + const expected = new PatchRequest(requestService.generateRequestId(), epersonsEndpoint + '/' + EPersonMock.uuid, operations); + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + }); + + describe('change name', () => { + const newFirstName = 'changedFirst'; + const newLastName = 'changedLast'; + beforeEach(() => { + const changedEPerson = Object.assign(new EPerson(), { + id: EPersonMock.id, + metadata: { + 'eperson.firstname': [ + { + value: newFirstName, + } + ], + 'eperson.lastname': [ + { + value: newLastName, + }, + ], + }, + email: EPersonMock.email, + canLogIn: EPersonMock.canLogIn, + requireCertificate: EPersonMock.requireCertificate, + _links: EPersonMock._links, + }); + service.updateEPerson(changedEPerson).subscribe(); + }); + it('should send PatchRequest with replace name metadata operations', () => { + const operations = [ + { op: 'replace', path: '/eperson.lastname/0/value', value: newLastName }, + { op: 'replace', path: '/eperson.firstname/0/value', value: newFirstName }]; + const expected = new PatchRequest(requestService.generateRequestId(), epersonsEndpoint + '/' + EPersonMock.uuid, operations); + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + }); + }); + + describe('clearEPersonRequests', () => { + beforeEach(async(() => { + scheduler = getTestScheduler(); + halService = { + getEndpoint(linkPath: string): Observable { + return observableOf(restEndpointURL + '/' + linkPath); + } + } as HALEndpointService; + initTestService(); + service.clearEPersonRequests(); + })); + it('should remove the eperson hrefs in the request service', () => { + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(epersonsEndpoint); + }); + }); + + describe('getActiveEPerson', () => { + it('should retrieve the ePerson currently getting edited, if any', () => { + service.editEPerson(EPersonMock); + + service.getActiveEPerson().subscribe((activeEPerson: EPerson) => { + expect(activeEPerson).toEqual(EPersonMock); + }) + }); + + it('should retrieve the ePerson currently getting edited, null if none being edited', () => { + service.getActiveEPerson().subscribe((activeEPerson: EPerson) => { + expect(activeEPerson).toEqual(null); + }) + }) + }); + + describe('cancelEditEPerson', () => { + it('should dispatch a CANCEL_EDIT_EPERSON action', () => { + service.cancelEditEPerson(); + expect(store.dispatch).toHaveBeenCalledWith(new EPeopleRegistryCancelEPersonAction()); + }); + }); + + describe('editEPerson', () => { + it('should dispatch a EDIT_EPERSON action with the EPerson to start editing', () => { + service.editEPerson(EPersonMock); + expect(store.dispatch).toHaveBeenCalledWith(new EPeopleRegistryEditEPersonAction(EPersonMock)); + }); + }); + + describe('deleteEPerson', () => { + beforeEach(() => { + spyOn(service, 'findById').and.returnValue(getRemotedataObservable(EPersonMock)); + service.deleteEPerson(EPersonMock).subscribe(); + }); + + it('should send DeleteRequest', () => { + const expected = new DeleteByIDRequest(requestService.generateRequestId(), epersonsEndpoint + '/' + EPersonMock.uuid, EPersonMock.uuid); + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + }); + +}); + +function getRemotedataObservable(obj: any): Observable> { + return observableOf(new RemoteData(false, false, true, undefined, obj)); +} + +class DummyChangeAnalyzer implements ChangeAnalyzer { + diff(object1: Item, object2: Item): Operation[] { + return compare((object1 as any).metadata, (object2 as any).metadata); + } +} diff --git a/src/app/core/eperson/eperson-data.service.ts b/src/app/core/eperson/eperson-data.service.ts index ef2e76c7c6..ec8b96d1cd 100644 --- a/src/app/core/eperson/eperson-data.service.ts +++ b/src/app/core/eperson/eperson-data.service.ts @@ -1,31 +1,52 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Store } from '@ngrx/store'; +import { createSelector, select, Store } from '@ngrx/store'; +import { Operation } from 'fast-json-patch/lib/core'; +import { Observable } from 'rxjs'; +import { filter, map, take } from 'rxjs/operators'; +import { + EPeopleRegistryCancelEPersonAction, + EPeopleRegistryEditEPersonAction +} from '../../+admin/admin-access-control/epeople-registry/epeople-registry.actions'; +import { EPeopleRegistryState } from '../../+admin/admin-access-control/epeople-registry/epeople-registry.reducers'; +import { AppState } from '../../app.reducer'; +import { hasValue } from '../../shared/empty.util'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { SearchParam } from '../cache/models/search-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { CoreState } from '../core.reducers'; +import { RestResponse } from '../cache/response.models'; import { DataService } from '../data/data.service'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; +import { PaginatedList } from '../data/paginated-list'; +import { RemoteData } from '../data/remote-data'; +import { FindListOptions, FindListRequest, PatchRequest, } from '../data/request.models'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { getRemoteDataPayload, getSucceededRemoteData } from '../shared/operators'; import { EPerson } from './models/eperson.model'; import { EPERSON } from './models/eperson.resource-type'; +const ePeopleRegistryStateSelector = (state: AppState) => state.epeopleRegistry; +const editEPersonSelector = createSelector(ePeopleRegistryStateSelector, (ePeopleRegistryState: EPeopleRegistryState) => ePeopleRegistryState.editEPerson); + /** - * A service to retrieve {@link EPerson}s from the REST API + * A service to retrieve {@link EPerson}s from the REST API & EPerson related CRUD actions */ @Injectable() @dataService(EPERSON) export class EPersonDataService extends DataService { - protected linkPath: 'epersons'; + protected linkPath = 'epersons'; + protected searchByEmailPath = 'byEmail'; + protected searchByMetadataPath = 'byMetadata'; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected store: Store, + protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, protected notificationsService: NotificationsService, @@ -35,4 +56,167 @@ export class EPersonDataService extends DataService { super(); } + /** + * Retrieves all EPeople + * @param options The options info used to retrieve the EPeople + */ + public getEPeople(options: FindListOptions = {}): Observable>> { + const hrefObs = this.getFindAllHref(options, this.linkPath); + hrefObs.pipe( + filter((href: string) => hasValue(href)), + take(1)) + .subscribe((href: string) => { + const request = new FindListRequest(this.requestService.generateRequestId(), href, options); + this.requestService.configure(request); + }); + + return this.rdbService.buildList(hrefObs) as Observable>>; + } + + /** + * Search the EPeople with a given scope and query + * @param scope Scope of the EPeople search, default byMetadata + * @param query Query of search + * @param options Options of search request + */ + public searchByScope(scope: string, query: string, options: FindListOptions = {}): Observable>> { + switch (scope) { + case 'metadata': + return this.getEpeopleByMetadata(query.trim(), options); + case 'email': + return this.getEpeopleByEmail(query.trim(), options); + default: + return this.getEpeopleByMetadata(query.trim(), options); + } + } + + /** + * Returns a search result list of EPeople, by email query (/eperson/epersons/search/{@link searchByEmailPath}?email=<>) + * @param query email query + * @param options + * @param linksToFollow + */ + private getEpeopleByEmail(query: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { + const searchParams = [new SearchParam('email', query)]; + return this.getEPeopleBy(searchParams, this.searchByEmailPath, options, ...linksToFollow); + } + + /** + * Returns a search result list of EPeople, by metadata query (/eperson/epersons/search/{@link searchByMetadataPath}?query=<>) + * @param query metadata query + * @param options + * @param linksToFollow + */ + private getEpeopleByMetadata(query: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { + const searchParams = [new SearchParam('query', query)]; + return this.getEPeopleBy(searchParams, this.searchByMetadataPath, options, ...linksToFollow); + } + + /** + * Returns a search result list of EPeople in a given searchMethod, with given searchParams + * @param searchParams query parameters in the search + * @param searchMethod searchBy path + * @param options + * @param linksToFollow + */ + private getEPeopleBy(searchParams: SearchParam[], searchMethod: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { + let findListOptions = new FindListOptions(); + if (options) { + findListOptions = Object.assign(new FindListOptions(), options); + } + if (findListOptions.searchParams) { + findListOptions.searchParams = [...findListOptions.searchParams, ...searchParams]; + } else { + findListOptions.searchParams = searchParams; + } + return this.searchBy(searchMethod, findListOptions, ...linksToFollow); + } + + /** + * Add a new patch to the object cache + * The patch is derived from the differences between the given object and its version in the object cache + * @param {DSpaceObject} ePerson The given object + */ + public updateEPerson(ePerson: EPerson): Observable { + const requestId = this.requestService.generateRequestId(); + const oldVersion$ = this.findByHref(ePerson._links.self.href); + oldVersion$.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + map((oldEPerson: EPerson) => { + const operations = this.generateOperations(oldEPerson, ePerson); + const patchRequest = new PatchRequest(requestId, ePerson._links.self.href, operations); + return this.requestService.configure(patchRequest); + }), + take(1) + ).subscribe(); + + return this.fetchResponse(requestId); + } + + /** + * Metadata operations are generated by the difference between old and new EPerson + * Custom replace operations for the other EPerson values + * @param oldEPerson + * @param newEPerson + */ + private generateOperations(oldEPerson: EPerson, newEPerson: EPerson): Operation[] { + let operations = this.comparator.diff(oldEPerson, newEPerson).filter((operation: Operation) => operation.op === 'replace'); + if (hasValue(oldEPerson.email) && oldEPerson.email !== newEPerson.email) { + operations = [...operations, { + op: 'replace', path: '/email', value: newEPerson.email + }] + } + if (hasValue(oldEPerson.requireCertificate) && oldEPerson.requireCertificate !== newEPerson.requireCertificate) { + operations = [...operations, { + op: 'replace', path: '/certificate', value: newEPerson.requireCertificate + }] + } + if (hasValue(oldEPerson.canLogIn) && oldEPerson.canLogIn !== newEPerson.canLogIn) { + operations = [...operations, { + op: 'replace', path: '/canLogIn', value: newEPerson.canLogIn + }] + } + return operations; + } + + /** + * Method that clears a cached EPerson request and returns its REST url + */ + public clearEPersonRequests(): void { + this.getBrowseEndpoint().pipe(take(1)).subscribe((link: string) => { + this.requestService.removeByHrefSubstring(link); + }); + } + + /** + * Method to retrieve the eperson that is currently being edited + */ + public getActiveEPerson(): Observable { + return this.store.pipe(select(editEPersonSelector)) + } + + /** + * Method to cancel editing an EPerson, dispatches a cancel EPerson action + */ + public cancelEditEPerson() { + this.store.dispatch(new EPeopleRegistryCancelEPersonAction()); + } + + /** + * Method to set the EPerson being edited, dispatches an edit EPerson action + * @param ePerson The EPerson to edit + */ + public editEPerson(ePerson: EPerson) { + this.store.dispatch(new EPeopleRegistryEditEPersonAction(ePerson)); + } + + /** + * Method to delete an EPerson + * @param ePerson The EPerson to delete + */ + public deleteEPerson(ePerson: EPerson): Observable { + return this.delete(ePerson.id); + } + } diff --git a/src/app/core/eperson/group-data.service.ts b/src/app/core/eperson/group-data.service.ts index 2beeb588a9..532f42323a 100644 --- a/src/app/core/eperson/group-data.service.ts +++ b/src/app/core/eperson/group-data.service.ts @@ -18,6 +18,8 @@ import { RemoteData } from '../data/remote-data'; import { PaginatedList } from '../data/paginated-list'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; +import { dataService } from '../cache/builders/build-decorators'; +import { GROUP } from './models/group.resource-type'; /** * Provides methods to retrieve eperson group resources. @@ -25,6 +27,7 @@ import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; @Injectable({ providedIn: 'root' }) +@dataService(GROUP) export class GroupDataService extends DataService { protected linkPath = 'groups'; protected browseEndpoint = ''; diff --git a/src/app/core/index/index.effects.ts b/src/app/core/index/index.effects.ts index c9f6eace8f..f885db1436 100644 --- a/src/app/core/index/index.effects.ts +++ b/src/app/core/index/index.effects.ts @@ -12,6 +12,7 @@ import { AddToIndexAction, RemoveFromIndexByValueAction } from './index.actions' import { hasValue } from '../../shared/empty.util'; import { IndexName } from './index.reducer'; import { RestRequestMethod } from '../data/rest-request-method'; +import { getUrlWithoutEmbedParams } from './index.selectors'; @Injectable() export class UUIDIndexEffects { @@ -47,7 +48,7 @@ export class UUIDIndexEffects { map((action: RequestConfigureAction) => { return new AddToIndexAction( IndexName.REQUEST, - action.payload.href, + getUrlWithoutEmbedParams(action.payload.href), action.payload.uuid ); }) diff --git a/src/app/core/index/index.selectors.spec.ts b/src/app/core/index/index.selectors.spec.ts new file mode 100644 index 0000000000..02cce4b7d6 --- /dev/null +++ b/src/app/core/index/index.selectors.spec.ts @@ -0,0 +1,32 @@ +import { getUrlWithoutEmbedParams } from './index.selectors'; + +describe(`index selectors`, () => { + + describe(`getUrlWithoutEmbedParams`, () => { + + it(`should return a url without its embed params`, () => { + const source = 'https://rest.api/resource?a=1&embed=2&b=3&embed=4/5&c=6&embed=7/8/9'; + const result = getUrlWithoutEmbedParams(source); + expect(result).toBe('https://rest.api/resource?a=1&b=3&c=6'); + }); + + it(`should return a url without embed params unmodified`, () => { + const source = 'https://rest.api/resource?a=1&b=3&c=6'; + const result = getUrlWithoutEmbedParams(source); + expect(result).toBe(source); + }); + + it(`should return a string that isn't a url unmodified`, () => { + const source = 'a=1&embed=2&b=3&embed=4/5&c=6&embed=7/8/9'; + const result = getUrlWithoutEmbedParams(source); + expect(result).toBe(source); + }); + + it(`should return undefined or null unmodified`, () => { + expect(getUrlWithoutEmbedParams(undefined)).toBe(undefined); + expect(getUrlWithoutEmbedParams(null)).toBe(null); + }); + + }); + +}); diff --git a/src/app/core/index/index.selectors.ts b/src/app/core/index/index.selectors.ts index de4adab09b..b23496c501 100644 --- a/src/app/core/index/index.selectors.ts +++ b/src/app/core/index/index.selectors.ts @@ -1,8 +1,41 @@ import { createSelector, MemoizedSelector } from '@ngrx/store'; -import { hasValue } from '../../shared/empty.util'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { CoreState } from '../core.reducers'; import { coreSelector } from '../core.selectors'; +import { URLCombiner } from '../url-combiner/url-combiner'; import { IndexName, IndexState, MetaIndexState } from './index.reducer'; +import * as parse from 'url-parse'; + +/** + * Return the given url without `embed` params. + * + * E.g. https://rest.api/resource?size=5&embed=subresource&rpp=3 + * becomes https://rest.api/resource?size=5&rpp=3 + * + * When you index a request url you don't want to include + * embed params because embedded data isn't relevant when + * you want to know + * + * @param url The url to use + */ +export const getUrlWithoutEmbedParams = (url: string): string => { + if (isNotEmpty(url)) { + const parsed = parse(url); + if (isNotEmpty(parsed.query)) { + const parts = parsed.query.split(/[?|&]/) + .filter((part: string) => isNotEmpty(part)) + .filter((part: string) => !part.startsWith('embed=')); + let args = ''; + if (isNotEmpty(parts)) { + args = `?${parts.join('&')}`; + } + url = new URLCombiner(parsed.origin, parsed.pathname, args).toString(); + return url; + } + } + + return url; +}; /** * Return the MetaIndexState based on the CoreSate @@ -74,7 +107,7 @@ export const selfLinkFromUuidSelector = export const uuidFromHrefSelector = (href: string): MemoizedSelector => createSelector( requestIndexSelector, - (state: IndexState) => hasValue(state) ? state[href] : undefined + (state: IndexState) => hasValue(state) ? state[getUrlWithoutEmbedParams(href)] : undefined ); /** 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/services/route.reducer.ts b/src/app/core/services/route.reducer.ts index 2d5356a5db..5a56fd3d9b 100644 --- a/src/app/core/services/route.reducer.ts +++ b/src/app/core/services/route.reducer.ts @@ -9,6 +9,7 @@ import { SetQueryParameterAction, SetQueryParametersAction } from './route.actions'; +import { isNotEmpty } from '../../shared/empty.util'; /** * Interface to represent the parameter state of a current route in the store @@ -81,7 +82,8 @@ function addParameter(state: RouteState, action: AddParameterAction | AddQueryPa * @param paramType The type of parameter to set: route or query parameter */ function setParameters(state: RouteState, action: SetParametersAction | SetQueryParametersAction, paramType: string): RouteState { - return Object.assign({}, state, { [paramType]: { [action.payload.key]: action.payload.value } }); + const param = isNotEmpty(action.payload) ? { [paramType]: { [action.payload.key]: action.payload.value } } : {}; + return Object.assign({}, state, param); } /** diff --git a/src/app/core/services/route.service.ts b/src/app/core/services/route.service.ts index 661f4acf94..59ec899576 100644 --- a/src/app/core/services/route.service.ts +++ b/src/app/core/services/route.service.ts @@ -6,7 +6,7 @@ import { combineLatest, Observable } from 'rxjs'; import { createSelector, MemoizedSelector, select, Store } from '@ngrx/store'; import { isEqual } from 'lodash'; -import { AddParameterAction, SetParameterAction, SetParametersAction, SetQueryParametersAction } from './route.actions'; +import { AddParameterAction, SetParameterAction, SetParametersAction, SetQueryParameterAction, SetQueryParametersAction } from './route.actions'; import { CoreState } from '../core.reducers'; import { coreSelector } from '../core.selectors'; import { hasValue } from '../../shared/empty.util'; @@ -59,7 +59,9 @@ export function parameterSelector(key: string, paramsSelector: (state: CoreState /** * Service to keep track of the current query parameters */ -@Injectable() +@Injectable({ + providedIn: 'root' +}) export class RouteService { constructor(private route: ActivatedRoute, private router: Router, private store: Store) { this.saveRouting(); @@ -194,6 +196,10 @@ export class RouteService { this.store.dispatch(new SetParameterAction(key, value)); } + public setQueryParameter(key, value) { + this.store.dispatch(new SetQueryParameterAction(key, value)); + } + /** * Sets the current route parameters and query parameters in the store */ 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/context.model.ts b/src/app/core/shared/context.model.ts index 7bfd613b65..6bb3d77140 100644 --- a/src/app/core/shared/context.model.ts +++ b/src/app/core/shared/context.model.ts @@ -10,4 +10,5 @@ export enum Context { Workspace = 'workspace', AdminMenu = 'adminMenu', SubmissionModal = 'submissionModal', + AdminSearch = 'adminSearch', } 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..7f6cf9fe13 100644 --- a/src/app/core/shared/item.model.ts +++ b/src/app/core/shared/item.model.ts @@ -17,13 +17,16 @@ 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'; +import { Version } from './version.model'; +import { VERSION } from './version.resource-type'; /** * Class representing a DSpace Item */ @typedObject @inheritSerialization(DSpaceObject) -export class Item extends DSpaceObject { +export class Item extends DSpaceObject implements ChildHALResource { static type = ITEM; /** @@ -66,6 +69,7 @@ export class Item extends DSpaceObject { bundles: HALLink; owningCollection: HALLink; templateItemOf: HALLink; + version: HALLink; self: HALLink; }; @@ -76,6 +80,13 @@ export class Item extends DSpaceObject { @link(COLLECTION) owningCollection?: Observable>; + /** + * The version this item represents in its history + * Will be undefined unless the version {@link HALLink} has been resolved. + */ + @link(VERSION) + version?: Observable>; + /** * The list of Bundles inside this Item * Will be undefined unless the bundles {@link HALLink} has been resolved. @@ -100,4 +111,8 @@ export class Item extends DSpaceObject { } return [entityType, ...super.getRenderTypes()]; } + + getParentLinkKey(): keyof this['_links'] { + return 'owningCollection'; + } } diff --git a/src/app/core/shared/search/search.service.ts b/src/app/core/shared/search/search.service.ts index 1a016e64f8..06dfd6dba0 100644 --- a/src/app/core/shared/search/search.service.ts +++ b/src/app/core/shared/search/search.service.ts @@ -2,7 +2,7 @@ import { combineLatest as observableCombineLatest, Observable, of as observableO import { Injectable, OnDestroy } from '@angular/core'; import { NavigationExtras, Router } from '@angular/router'; import { first, map, switchMap, tap } from 'rxjs/operators'; -import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { LinkService } from '../../cache/builders/link.service'; import { FacetConfigSuccessResponse, FacetValueSuccessResponse, SearchSuccessResponse } from '../../cache/response.models'; import { PaginatedList } from '../../data/paginated-list'; @@ -107,10 +107,11 @@ export class SearchService implements OnDestroy { * Method to retrieve a paginated list of search results from the server * @param {PaginatedSearchOptions} searchOptions The configuration necessary to perform this search * @param responseMsToLive The amount of milliseconds for the response to live in cache + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved * @returns {Observable>>>} Emits a paginated list with all search results found */ - search(searchOptions?: PaginatedSearchOptions, responseMsToLive?: number): Observable>>> { - return this.getPaginatedResults(this.searchEntries(searchOptions)); + search(searchOptions?: PaginatedSearchOptions, responseMsToLive?: number, ...linksToFollow: Array>): Observable>>> { + return this.getPaginatedResults(this.searchEntries(searchOptions), ...linksToFollow); } /** @@ -151,9 +152,10 @@ export class SearchService implements OnDestroy { /** * Method to convert the parsed responses into a paginated list of search results * @param searchEntries: The request entries from the search method + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved * @returns {Observable>>>} Emits a paginated list with all search results found */ - getPaginatedResults(searchEntries: Observable<{ searchOptions: PaginatedSearchOptions, requestEntry: RequestEntry }>): Observable>>> { + getPaginatedResults(searchEntries: Observable<{ searchOptions: PaginatedSearchOptions, requestEntry: RequestEntry }>, ...linksToFollow: Array>): Observable>>> { const requestEntryObs: Observable = searchEntries.pipe( map((entry) => entry.requestEntry), ); @@ -174,7 +176,7 @@ export class SearchService implements OnDestroy { }), // Send a request for each item to ensure fresh cache tap((reqs: RestRequest[]) => reqs.forEach((req: RestRequest) => this.requestService.configure(req))), - map((reqs: RestRequest[]) => reqs.map((req: RestRequest) => this.rdb.buildSingle(req.href))), + map((reqs: RestRequest[]) => reqs.map((req: RestRequest) => this.rdb.buildSingle(req.href, ...linksToFollow))), switchMap((input: Array>>) => this.rdb.aggregate(input)), ); diff --git a/src/app/core/shared/version-history.model.ts b/src/app/core/shared/version-history.model.ts new file mode 100644 index 0000000000..a8ce982fb2 --- /dev/null +++ b/src/app/core/shared/version-history.model.ts @@ -0,0 +1,39 @@ +import { deserialize, autoserialize, inheritSerialization } from 'cerialize'; +import { excludeFromEquals } from '../utilities/equals.decorators'; +import { Observable } from 'rxjs/internal/Observable'; +import { RemoteData } from '../data/remote-data'; +import { PaginatedList } from '../data/paginated-list'; +import { Version } from './version.model'; +import { VERSION_HISTORY } from './version-history.resource-type'; +import { link, typedObject } from '../cache/builders/build-decorators'; +import { DSpaceObject } from './dspace-object.model'; +import { HALLink } from './hal-link.model'; +import { VERSION } from './version.resource-type'; + +/** + * Class representing a DSpace Version History + */ +@typedObject +@inheritSerialization(DSpaceObject) +export class VersionHistory extends DSpaceObject { + static type = VERSION_HISTORY; + + @deserialize + _links: { + self: HALLink; + versions: HALLink; + }; + + /** + * The identifier of this Version History + */ + @autoserialize + id: string; + + /** + * The list of versions within this history + */ + @excludeFromEquals + @link(VERSION, true) + versions: Observable>>; +} diff --git a/src/app/core/shared/version-history.resource-type.ts b/src/app/core/shared/version-history.resource-type.ts new file mode 100644 index 0000000000..c6d92ce138 --- /dev/null +++ b/src/app/core/shared/version-history.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for VersionHistory + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const VERSION_HISTORY = new ResourceType('versionhistory'); diff --git a/src/app/core/shared/version.model.ts b/src/app/core/shared/version.model.ts new file mode 100644 index 0000000000..6e109ba9c2 --- /dev/null +++ b/src/app/core/shared/version.model.ts @@ -0,0 +1,76 @@ +import { deserialize, autoserialize, inheritSerialization } from 'cerialize'; +import { excludeFromEquals } from '../utilities/equals.decorators'; +import { Item } from './item.model'; +import { RemoteData } from '../data/remote-data'; +import { Observable } from 'rxjs/internal/Observable'; +import { VersionHistory } from './version-history.model'; +import { EPerson } from '../eperson/models/eperson.model'; +import { VERSION } from './version.resource-type'; +import { HALLink } from './hal-link.model'; +import { link, typedObject } from '../cache/builders/build-decorators'; +import { VERSION_HISTORY } from './version-history.resource-type'; +import { ITEM } from './item.resource-type'; +import { EPERSON } from '../eperson/models/eperson.resource-type'; +import { DSpaceObject } from './dspace-object.model'; + +/** + * Class representing a DSpace Version + */ +@typedObject +@inheritSerialization(DSpaceObject) +export class Version extends DSpaceObject { + static type = VERSION; + + @deserialize + _links: { + self: HALLink; + item: HALLink; + versionhistory: HALLink; + eperson: HALLink; + }; + + /** + * The identifier of this Version + */ + @autoserialize + id: string; + + /** + * The version number of the version's history this version represents + */ + @autoserialize + version: number; + + /** + * The summary for the changes made in this version + */ + @autoserialize + summary: string; + + /** + * The Date this version was created + */ + @deserialize + created: Date; + + /** + * The full version history this version is apart of + */ + @excludeFromEquals + @link(VERSION_HISTORY) + versionhistory: Observable>; + + /** + * The item this version represents + */ + @excludeFromEquals + @link(ITEM) + item: Observable>; + + /** + * The e-person who created this version + */ + @excludeFromEquals + @link(EPERSON) + eperson: Observable>; +} diff --git a/src/app/core/shared/version.resource-type.ts b/src/app/core/shared/version.resource-type.ts new file mode 100644 index 0000000000..ac0f56239e --- /dev/null +++ b/src/app/core/shared/version.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for Version + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const VERSION = new ResourceType('version'); diff --git a/src/app/core/submission/submission-rest.service.spec.ts b/src/app/core/submission/submission-rest.service.spec.ts index eefc815435..68d7ff13f4 100644 --- a/src/app/core/submission/submission-rest.service.spec.ts +++ b/src/app/core/submission/submission-rest.service.spec.ts @@ -26,7 +26,7 @@ describe('SubmissionRestService test suite', () => { const resourceEndpoint = 'workspaceitems'; const resourceScope = '260'; const body = { test: new FormFieldMetadataValueObject('test')}; - const resourceHref = resourceEndpointURL + '/' + resourceEndpoint + '/' + resourceScope; + const resourceHref = resourceEndpointURL + '/' + resourceEndpoint + '/' + resourceScope + '?projection=full'; const timestampResponse = 1545994811992; function initTestService() { diff --git a/src/app/core/submission/submission-rest.service.ts b/src/app/core/submission/submission-rest.service.ts index 32ba070002..350874bc50 100644 --- a/src/app/core/submission/submission-rest.service.ts +++ b/src/app/core/submission/submission-rest.service.ts @@ -71,8 +71,9 @@ export class SubmissionRestService { */ protected getEndpointByIDHref(endpoint, resourceID, collectionId?: string): string { let url = isNotEmpty(resourceID) ? `${endpoint}/${resourceID}` : `${endpoint}`; + url = new URLCombiner(url, '?projection=full').toString(); if (collectionId) { - url = new URLCombiner(url, `?owningCollection=${collectionId}`).toString(); + url = new URLCombiner(url, `&owningCollection=${collectionId}`).toString(); } return url; } diff --git a/src/app/core/tasks/models/claimed-task-object.model.ts b/src/app/core/tasks/models/claimed-task-object.model.ts index 9ec28bc2e0..3ea78595f2 100644 --- a/src/app/core/tasks/models/claimed-task-object.model.ts +++ b/src/app/core/tasks/models/claimed-task-object.model.ts @@ -1,6 +1,5 @@ import { inheritSerialization } from 'cerialize'; -import { typedObject } from '../../cache/builders/build-decorators'; -import { DSpaceObject } from '../../shared/dspace-object.model'; +import { inheritLinkAnnotations, typedObject } from '../../cache/builders/build-decorators'; import { CLAIMED_TASK } from './claimed-task-object.resource-type'; import { TaskObject } from './task-object.model'; @@ -8,7 +7,8 @@ import { TaskObject } from './task-object.model'; * A model class for a ClaimedTask. */ @typedObject -@inheritSerialization(DSpaceObject) +@inheritSerialization(TaskObject) +@inheritLinkAnnotations(TaskObject) export class ClaimedTask extends TaskObject { static type = CLAIMED_TASK; } diff --git a/src/app/core/tasks/models/pool-task-object.model.ts b/src/app/core/tasks/models/pool-task-object.model.ts index 04fe572502..501849e8ec 100644 --- a/src/app/core/tasks/models/pool-task-object.model.ts +++ b/src/app/core/tasks/models/pool-task-object.model.ts @@ -1,5 +1,5 @@ import { inheritSerialization } from 'cerialize'; -import { typedObject } from '../../cache/builders/build-decorators'; +import { inheritLinkAnnotations, typedObject } from '../../cache/builders/build-decorators'; import { POOL_TASK } from './pool-task-object.resource-type'; import { TaskObject } from './task-object.model'; @@ -8,6 +8,7 @@ import { TaskObject } from './task-object.model'; */ @typedObject @inheritSerialization(TaskObject) +@inheritLinkAnnotations(TaskObject) export class PoolTask extends TaskObject { static type = POOL_TASK; } diff --git a/src/app/core/tasks/models/task-object.model.ts b/src/app/core/tasks/models/task-object.model.ts index ac659ca9e5..b56cec3a7e 100644 --- a/src/app/core/tasks/models/task-object.model.ts +++ b/src/app/core/tasks/models/task-object.model.ts @@ -12,6 +12,7 @@ import { DSpaceObject } from '../../shared/dspace-object.model'; import { HALLink } from '../../shared/hal-link.model'; import { WorkflowItem } from '../../submission/models/workflowitem.model'; import { TASK_OBJECT } from './task-object.resource-type'; +import { WORKFLOWITEM } from '../../eperson/models/workflowitem.resource-type'; /** * An abstract model class for a TaskObject. @@ -45,7 +46,7 @@ export class TaskObject extends DSpaceObject implements CacheableObject { @deserialize _links: { self: HALLink; - eperson: HALLink; + owner: HALLink; group: HALLink; workflowitem: HALLink; }; @@ -54,7 +55,7 @@ export class TaskObject extends DSpaceObject implements CacheableObject { * The EPerson for this task * Will be undefined unless the eperson {@link HALLink} has been resolved. */ - @link(EPERSON) + @link(EPERSON, false, 'owner') eperson?: Observable>; /** @@ -68,7 +69,7 @@ export class TaskObject extends DSpaceObject implements CacheableObject { * The WorkflowItem for this task * Will be undefined unless the workflowitem {@link HALLink} has been resolved. */ - @link(WorkflowItem.type) + @link(WORKFLOWITEM) workflowitem?: Observable> | WorkflowItem; } diff --git a/src/app/core/url-combiner/url-combiner.ts b/src/app/core/url-combiner/url-combiner.ts index ae622ab976..e7468c6107 100644 --- a/src/app/core/url-combiner/url-combiner.ts +++ b/src/app/core/url-combiner/url-combiner.ts @@ -41,8 +41,8 @@ export class URLCombiner { // remove consecutive slashes url = url.replace(/([^:\s])\/+/g, '$1/'); - // remove trailing slash before parameters or hash - url = url.replace(/\/(\?|&|#[^!])/g, '$1'); + // remove trailing slash + url = url.replace(/\/($|\?|&|#[^!])/g, '$1'); // replace ? in parameters with & url = url.replace(/(\?.+)\?/g, '$1&'); diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-issue/journal-issue-search-result-grid-element.component.html b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-issue/journal-issue-search-result-grid-element.component.html index af339109c6..0f9b5894f9 100644 --- a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-issue/journal-issue-search-result-grid-element.component.html +++ b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-issue/journal-issue-search-result-grid-element.component.html @@ -1,36 +1,43 @@ - -
- -
- - -
-
- +
+ + + +
+ + +
+
+
-
- - -

-
-

- - - -

-

- - - -

-
- View -
-
+
+ + +

+
+

+ + + +

+

+ + + +

+
+ View +
-
+ + +
diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-volume/journal-volume-search-result-grid-element.component.html b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-volume/journal-volume-search-result-grid-element.component.html index 2c7f24662a..16e2a8b847 100644 --- a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-volume/journal-volume-search-result-grid-element.component.html +++ b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal-volume/journal-volume-search-result-grid-element.component.html @@ -1,36 +1,43 @@ - -
- -
- - -
-
- +
+ + + +
+ + +
+
+
-
- - -

-
-

- - - -

-

- - - -

-
- View -
-
-
- +
+ + +

+
+

+ + + +

+

+ + + +

+
+ View +
+
+ + +
diff --git a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal/journal-search-result-grid-element.component.html b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal/journal-search-result-grid-element.component.html index d6b9c4a62e..4902eec71e 100644 --- a/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal/journal-search-result-grid-element.component.html +++ b/src/app/entity-groups/journal-entities/item-grid-elements/search-result-grid-elements/journal/journal-search-result-grid-element.component.html @@ -1,41 +1,47 @@ - -
- -
- - -
-
- +
+ + + +
+ + +
+
+
-
- - -

-
-

- - {{firstMetadataValue('creativework.editor')}} - +

+ + +

+
+

+ + {{firstMetadataValue('creativework.editor')}} + , - {{firstMetadataValue('creativework.publisher')}} + {{firstMetadataValue('creativework.publisher')}} - -

-

- - - -

-
- View -
-
+ +

+

+ + + +

+
+ View +
-
+ + +
diff --git a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/org-unit/org-unit-search-result-grid-element.component.html b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/org-unit/org-unit-search-result-grid-element.component.html index 0fb1ec02f8..1f64856583 100644 --- a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/org-unit/org-unit-search-result-grid-element.component.html +++ b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/org-unit/org-unit-search-result-grid-element.component.html @@ -1,41 +1,49 @@ - -
- -
- - -
-
- +
+ + + +
+ + +
+
+
-
- - -

-
-

- - - -

-

- - {{firstMetadataValue('organization.address.addressCountry')}} - +

+ + +

+
+

+ + + +

+

+ + {{firstMetadataValue('organization.address.addressCountry')}} + , - {{firstMetadataValue('organization.address.addressLocality')}} + {{firstMetadataValue('organization.address.addressLocality')}} - -

-
- View -
-
+ +

+
+ View +
-
+ + +
diff --git a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/person/person-search-result-grid-element.component.html b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/person/person-search-result-grid-element.component.html index 321ecd4a47..cbe93b2545 100644 --- a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/person/person-search-result-grid-element.component.html +++ b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/person/person-search-result-grid-element.component.html @@ -1,37 +1,43 @@ - -
- -
- - -
-
- +
+ + + +
+ + +
+
+
-
- - -

-
- -

- - - -

-
- View -
-
+
+ + +

+
+ +

+ + + +

+
+ View +
-
+ + +
diff --git a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html index c39de6bc2a..22182d50be 100644 --- a/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html +++ b/src/app/entity-groups/research-entities/item-grid-elements/search-result-grid-elements/project/project-search-result-grid-element.component.html @@ -1,31 +1,37 @@ - -
- -
- - -
-
- +
+ + + +
+ + +
+
+
-
- - -

-
-

- - - -

-
- View -
-
+
+ + +

+
+

+ + + +

+
+ View +
-
+ + +
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 37cbe47c72..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,16 +23,16 @@ describe('OrgUnitItemMetadataListElementComponent', () => { declarations: [OrgUnitItemMetadataListElementComponent], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(OrgUnitItemMetadataListElementComponent, { - // set: { changeDetection: ChangeDetectionStrategy.Default } + set: { changeDetection: ChangeDetectionStrategy.Default } }).compileComponents(); })); - beforeEach(async(() => { + beforeEach(() => { fixture = TestBed.createComponent(OrgUnitItemMetadataListElementComponent); comp = fixture.componentInstance; comp.metadataRepresentation = mockItemMetadataRepresentation; fixture.detectChanges(); - })); + }); it('should show the name of the organisation as a link', () => { const linkText = fixture.debugElement.query(By.css('a')).nativeElement.textContent; 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/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.html b/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.html new file mode 100644 index 0000000000..c1c1cff0f3 --- /dev/null +++ b/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.html @@ -0,0 +1,6 @@ + + diff --git a/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.spec.ts b/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.spec.ts new file mode 100644 index 0000000000..7aeb33d84d --- /dev/null +++ b/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.spec.ts @@ -0,0 +1,142 @@ +import { ProfilePageMetadataFormComponent } from './profile-page-metadata-form.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { EventEmitter, NO_ERRORS_SCHEMA } from '@angular/core'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { GLOBAL_CONFIG } from '../../../config'; +import { FormBuilderService } from '../../shared/form/builder/form-builder.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { EPersonDataService } from '../../core/eperson/eperson-data.service'; +import { cloneDeep } from 'lodash'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils'; + +describe('ProfilePageMetadataFormComponent', () => { + let component: ProfilePageMetadataFormComponent; + let fixture: ComponentFixture; + + const config = { + languages: [{ + code: 'en', + label: 'English', + active: true, + }, { + code: 'de', + label: 'Deutsch', + active: true, + }] + } as any; + + const user = Object.assign(new EPerson(), { + email: 'example@gmail.com', + metadata: { + 'eperson.firstname': [ + { + value: 'John', + language: null + } + ], + 'eperson.lastname': [ + { + value: 'Doe', + language: null + } + ], + 'eperson.language': [ + { + value: 'de', + language: null + } + ] + } + }); + + const epersonService = jasmine.createSpyObj('epersonService', { + update: createSuccessfulRemoteDataObject$(user) + }); + const notificationsService = jasmine.createSpyObj('notificationsService', { + success: {}, + error: {}, + warning: {} + }); + const translate = { + instant: () => 'translated', + onLangChange: new EventEmitter() + }; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ProfilePageMetadataFormComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: GLOBAL_CONFIG, useValue: config }, + { provide: EPersonDataService, useValue: epersonService }, + { provide: TranslateService, useValue: translate }, + { provide: NotificationsService, useValue: notificationsService }, + FormBuilderService + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ProfilePageMetadataFormComponent); + component = fixture.componentInstance; + component.user = user; + fixture.detectChanges(); + }); + + it('should automatically fill in the user\'s email in the correct field', () => { + expect(component.formGroup.get('email').value).toEqual(user.email); + }); + + it('should automatically fill the present metadata values and leave missing ones empty', () => { + expect(component.formGroup.get('firstname').value).toEqual('John'); + expect(component.formGroup.get('lastname').value).toEqual('Doe'); + expect(component.formGroup.get('phone').value).toBeUndefined(); + expect(component.formGroup.get('language').value).toEqual('de'); + }); + + describe('updateProfile', () => { + describe('when no values changed', () => { + let result; + + beforeEach(() => { + result = component.updateProfile(); + }); + + it('should return false', () => { + expect(result).toEqual(false); + }); + + it('should not call epersonService.update', () => { + expect(epersonService.update).not.toHaveBeenCalled(); + }); + }); + + describe('when a form value changed', () => { + let result; + let newUser; + + beforeEach(() => { + newUser = cloneDeep(user); + newUser.metadata['eperson.firstname'][0].value = 'Johnny'; + setModelValue('firstname', 'Johnny'); + result = component.updateProfile(); + }); + + it('should return true', () => { + expect(result).toEqual(true); + }); + + it('should call epersonService.update', () => { + expect(epersonService.update).toHaveBeenCalledWith(newUser); + }); + }); + }); + + function setModelValue(id: string, value: string) { + component.formModel.filter((model) => model.id === id).forEach((model) => (model as any).value = value); + } +}); diff --git a/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts b/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts new file mode 100644 index 0000000000..b44faa8c4a --- /dev/null +++ b/src/app/profile-page/profile-page-metadata-form/profile-page-metadata-form.component.ts @@ -0,0 +1,212 @@ +import { Component, Inject, Input, OnInit } from '@angular/core'; +import { + DynamicFormControlModel, + DynamicFormService, DynamicFormValueControlModel, + DynamicInputModel, DynamicSelectModel +} from '@ng-dynamic-forms/core'; +import { FormGroup } from '@angular/forms'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { TranslateService } from '@ngx-translate/core'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; +import { LangConfig } from '../../../config/lang-config.interface'; +import { EPersonDataService } from '../../core/eperson/eperson-data.service'; +import { cloneDeep } from 'lodash'; +import { getRemoteDataPayload, getSucceededRemoteData } from '../../core/shared/operators'; +import { FormBuilderService } from '../../shared/form/builder/form-builder.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; + +@Component({ + selector: 'ds-profile-page-metadata-form', + templateUrl: './profile-page-metadata-form.component.html' +}) +/** + * Component for a user to edit their metadata + * Displays a form containing: + * - readonly email field, + * - required first name text field + * - required last name text field + * - phone text field + * - language dropdown + */ +export class ProfilePageMetadataFormComponent implements OnInit { + /** + * The user to display the form for + */ + @Input() user: EPerson; + + /** + * The form's input models + */ + formModel: DynamicFormControlModel[] = [ + new DynamicInputModel({ + id: 'email', + name: 'email', + readOnly: true + }), + new DynamicInputModel({ + id: 'firstname', + name: 'eperson.firstname', + required: true, + validators: { + required: null + }, + errorMessages: { + required: 'This field is required' + }, + }), + new DynamicInputModel({ + id: 'lastname', + name: 'eperson.lastname', + required: true, + validators: { + required: null + }, + errorMessages: { + required: 'This field is required' + }, + }), + new DynamicInputModel({ + id: 'phone', + name: 'eperson.phone' + }), + new DynamicSelectModel({ + id: 'language', + name: 'eperson.language' + }) + ]; + + /** + * The form group of this form + */ + formGroup: FormGroup; + + /** + * Prefix for the form's label messages of this component + */ + LABEL_PREFIX = 'profile.metadata.form.label.'; + + /** + * Prefix for the form's error messages of this component + */ + ERROR_PREFIX = 'profile.metadata.form.error.'; + + /** + * Prefix for the notification messages of this component + */ + NOTIFICATION_PREFIX = 'profile.metadata.form.notifications.'; + + /** + * All of the configured active languages + * Used to populate the language dropdown + */ + activeLangs: LangConfig[]; + + constructor(@Inject(GLOBAL_CONFIG) protected config: GlobalConfig, + protected formBuilderService: FormBuilderService, + protected translate: TranslateService, + protected epersonService: EPersonDataService, + protected notificationsService: NotificationsService) { + } + + ngOnInit(): void { + this.activeLangs = this.config.languages.filter((MyLangConfig) => MyLangConfig.active === true); + this.setFormValues(); + this.updateFieldTranslations(); + this.translate.onLangChange + .subscribe(() => { + this.updateFieldTranslations(); + }); + } + + /** + * Loop over all the form's input models and set their values depending on the user's metadata + * Create the FormGroup + */ + setFormValues() { + this.formModel.forEach( + (fieldModel: DynamicInputModel | DynamicSelectModel) => { + if (fieldModel.name === 'email') { + fieldModel.value = this.user.email; + } else { + fieldModel.value = this.user.firstMetadataValue(fieldModel.name); + } + if (fieldModel.id === 'language') { + (fieldModel as DynamicSelectModel).options = + this.activeLangs.map((langConfig) => Object.assign({ value: langConfig.code, label: langConfig.label })) + } + } + ); + this.formGroup = this.formBuilderService.createFormGroup(this.formModel); + } + + /** + * Update the translations of the field labels and error messages + */ + updateFieldTranslations() { + this.formModel.forEach( + (fieldModel: DynamicInputModel) => { + fieldModel.label = this.translate.instant(this.LABEL_PREFIX + fieldModel.id); + if (isNotEmpty(fieldModel.validators)) { + fieldModel.errorMessages = {}; + Object.keys(fieldModel.validators).forEach((key) => { + fieldModel.errorMessages[key] = this.translate.instant(this.ERROR_PREFIX + fieldModel.id + '.' + key); + }); + } + } + ); + } + + /** + * Update the user's metadata + * + * Sends a patch request for updating the user's metadata when at least one value changed or got added/removed and the + * form is valid. + * Nothing happens when the form is invalid or no metadata changed. + * + * Returns false when nothing happened. + */ + updateProfile(): boolean { + if (!this.formGroup.valid) { + return false; + } + + const newMetadata = cloneDeep(this.user.metadata); + let changed = false; + this.formModel.filter((fieldModel) => fieldModel.id !== 'email').forEach((fieldModel: DynamicFormValueControlModel) => { + if (newMetadata.hasOwnProperty(fieldModel.name) && newMetadata[fieldModel.name].length > 0) { + if (hasValue(fieldModel.value)) { + if (newMetadata[fieldModel.name][0].value !== fieldModel.value) { + newMetadata[fieldModel.name][0].value = fieldModel.value; + changed = true; + } + } else { + newMetadata[fieldModel.name] = []; + changed = true; + } + } else if (hasValue(fieldModel.value)) { + newMetadata[fieldModel.name] = [{ + value: fieldModel.value, + language: null + } as any]; + changed = true; + } + }); + + if (changed) { + this.epersonService.update(Object.assign(cloneDeep(this.user), {metadata: newMetadata})).pipe( + getSucceededRemoteData(), + getRemoteDataPayload() + ).subscribe((user) => { + this.user = user; + this.setFormValues(); + this.notificationsService.success( + this.translate.instant(this.NOTIFICATION_PREFIX + 'success.title'), + this.translate.instant(this.NOTIFICATION_PREFIX + 'success.content') + ); + }); + } + + return changed; + } +} diff --git a/src/app/profile-page/profile-page-routing.module.ts b/src/app/profile-page/profile-page-routing.module.ts new file mode 100644 index 0000000000..4b9f2b7fff --- /dev/null +++ b/src/app/profile-page/profile-page-routing.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver'; +import { ProfilePageComponent } from './profile-page.component'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { path: '', pathMatch: 'full', component: ProfilePageComponent, resolve: { breadcrumb: I18nBreadcrumbResolver }, data: { breadcrumbKey: 'profile', title: 'profile.title' } } + ]) + ] +}) +export class ProfilePageRoutingModule { + +} diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html new file mode 100644 index 0000000000..50a081c6f2 --- /dev/null +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.html @@ -0,0 +1,9 @@ +
{{'profile.security.form.info' | translate}}
+ + +
{{'profile.security.form.error.password-length' | translate}}
+
{{'profile.security.form.error.matching-passwords' | translate}}
diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts new file mode 100644 index 0000000000..324230ce9f --- /dev/null +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.spec.ts @@ -0,0 +1,110 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { VarDirective } from '../../shared/utils/var.directive'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { EPersonDataService } from '../../core/eperson/eperson-data.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { FormBuilderService } from '../../shared/form/builder/form-builder.service'; +import { ProfilePageSecurityFormComponent } from './profile-page-security-form.component'; +import { of as observableOf } from 'rxjs'; +import { RestResponse } from '../../core/cache/response.models'; + +describe('ProfilePageSecurityFormComponent', () => { + let component: ProfilePageSecurityFormComponent; + let fixture: ComponentFixture; + + const user = Object.assign(new EPerson(), { + _links: { + self: { href: 'user-selflink' } + } + }); + + const epersonService = jasmine.createSpyObj('epersonService', { + patch: observableOf(new RestResponse(true, 200, 'OK')) + }); + const notificationsService = jasmine.createSpyObj('notificationsService', { + success: {}, + error: {}, + warning: {} + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ProfilePageSecurityFormComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: EPersonDataService, useValue: epersonService }, + { provide: NotificationsService, useValue: notificationsService }, + FormBuilderService + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ProfilePageSecurityFormComponent); + component = fixture.componentInstance; + component.user = user; + fixture.detectChanges(); + }); + + describe('updateSecurity', () => { + describe('when no values changed', () => { + let result; + + beforeEach(() => { + result = component.updateSecurity(); + }); + + it('should return false', () => { + expect(result).toEqual(false); + }); + + it('should not call epersonService.patch', () => { + expect(epersonService.patch).not.toHaveBeenCalled(); + }); + }); + + describe('when password is filled in, but the confirm field is empty', () => { + let result; + + beforeEach(() => { + setModelValue('password', 'test'); + result = component.updateSecurity(); + }); + + it('should return true', () => { + expect(result).toEqual(true); + }); + }); + + describe('when both password fields are filled in, long enough and equal', () => { + let result; + let operations; + + beforeEach(() => { + setModelValue('password', 'testest'); + setModelValue('passwordrepeat', 'testest'); + operations = [{ op: 'replace', path: '/password', value: 'testest' }]; + result = component.updateSecurity(); + }); + + it('should return true', () => { + expect(result).toEqual(true); + }); + + it('should return call epersonService.patch', () => { + expect(epersonService.patch).toHaveBeenCalledWith(user, operations); + }); + }); + }); + + function setModelValue(id: string, value: string) { + component.formGroup.patchValue({ + [id]: value + }); + component.formGroup.markAllAsTouched(); + } +}); diff --git a/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts new file mode 100644 index 0000000000..b8ac07e6d8 --- /dev/null +++ b/src/app/profile-page/profile-page-security-form/profile-page-security-form.component.ts @@ -0,0 +1,151 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { + DynamicFormControlModel, + DynamicFormService, + DynamicInputModel +} from '@ng-dynamic-forms/core'; +import { TranslateService } from '@ngx-translate/core'; +import { FormGroup } from '@angular/forms'; +import { isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { EPersonDataService } from '../../core/eperson/eperson-data.service'; +import { EPerson } from '../../core/eperson/models/eperson.model'; +import { ErrorResponse, RestResponse } from '../../core/cache/response.models'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; + +@Component({ + selector: 'ds-profile-page-security-form', + templateUrl: './profile-page-security-form.component.html' +}) +/** + * Component for a user to edit their security information + * Displays a form containing a password field and a confirmation of the password + */ +export class ProfilePageSecurityFormComponent implements OnInit { + /** + * The user to display the form for + */ + @Input() user: EPerson; + + /** + * The form's input models + */ + formModel: DynamicFormControlModel[] = [ + new DynamicInputModel({ + id: 'password', + name: 'password', + inputType: 'password' + }), + new DynamicInputModel({ + id: 'passwordrepeat', + name: 'passwordrepeat', + inputType: 'password' + }) + ]; + + /** + * The form group of this form + */ + formGroup: FormGroup; + + /** + * Prefix for the notification messages of this component + */ + NOTIFICATIONS_PREFIX = 'profile.security.form.notifications.'; + + /** + * Prefix for the form's label messages of this component + */ + LABEL_PREFIX = 'profile.security.form.label.'; + + constructor(protected formService: DynamicFormService, + protected translate: TranslateService, + protected epersonService: EPersonDataService, + protected notificationsService: NotificationsService) { + } + + ngOnInit(): void { + this.formGroup = this.formService.createFormGroup(this.formModel, { validators: [this.checkPasswordsEqual, this.checkPasswordLength] }); + this.updateFieldTranslations(); + this.translate.onLangChange + .subscribe(() => { + this.updateFieldTranslations(); + }); + } + + /** + * Update the translations of the field labels + */ + updateFieldTranslations() { + this.formModel.forEach( + (fieldModel: DynamicInputModel) => { + fieldModel.label = this.translate.instant(this.LABEL_PREFIX + fieldModel.id); + } + ); + } + + /** + * Check if both password fields are filled in and equal + * @param group The FormGroup to validate + */ + checkPasswordsEqual(group: FormGroup) { + const pass = group.get('password').value; + const repeatPass = group.get('passwordrepeat').value; + + return pass === repeatPass ? null : { notSame: true }; + } + + /** + * Check if the password is at least 6 characters long + * @param group The FormGroup to validate + */ + checkPasswordLength(group: FormGroup) { + const pass = group.get('password').value; + + return isEmpty(pass) || pass.length >= 6 ? null : { notLongEnough: true }; + } + + /** + * Update the user's security details + * + * Sends a patch request for changing the user's password when a new password is present and the password confirmation + * matches the new password. + * Nothing happens when no passwords are filled in. + * An error notification is displayed when the password confirmation does not match the new password. + * + * Returns false when nothing happened + */ + updateSecurity() { + const pass = this.formGroup.get('password').value; + const passEntered = isNotEmpty(pass); + if (!this.formGroup.valid) { + if (passEntered) { + if (this.checkPasswordsEqual(this.formGroup) != null) { + this.notificationsService.error(this.translate.instant(this.NOTIFICATIONS_PREFIX + 'error.not-same')); + } + if (this.checkPasswordLength(this.formGroup) != null) { + this.notificationsService.error(this.translate.instant(this.NOTIFICATIONS_PREFIX + 'error.not-long-enough')); + } + return true; + } + return false; + } + if (passEntered) { + const operation = Object.assign({ op: 'replace', path: '/password', value: pass }); + this.epersonService.patch(this.user, [operation]).subscribe((response: RestResponse) => { + if (response.isSuccessful) { + this.notificationsService.success( + this.translate.instant(this.NOTIFICATIONS_PREFIX + 'success.title'), + this.translate.instant(this.NOTIFICATIONS_PREFIX + 'success.content') + ); + } else { + this.notificationsService.error( + this.translate.instant(this.NOTIFICATIONS_PREFIX + 'error.title'), (response as ErrorResponse).errorMessage + ); + } + }); + + } + + return passEntered; + } +} diff --git a/src/app/profile-page/profile-page.component.html b/src/app/profile-page/profile-page.component.html new file mode 100644 index 0000000000..b6e62665b4 --- /dev/null +++ b/src/app/profile-page/profile-page.component.html @@ -0,0 +1,27 @@ + +
+

{{'profile.head' | translate}}

+
+
{{'profile.card.identify' | translate}}
+
+ +
+
+
+
{{'profile.card.security' | translate}}
+
+ +
+
+ + + +
+

{{'profile.groups.head' | translate}}

+
    +
  • {{group.name}}
  • +
+
+
+
+
diff --git a/src/app/profile-page/profile-page.component.spec.ts b/src/app/profile-page/profile-page.component.spec.ts new file mode 100644 index 0000000000..5992012be9 --- /dev/null +++ b/src/app/profile-page/profile-page.component.spec.ts @@ -0,0 +1,129 @@ +import { ProfilePageComponent } from './profile-page.component'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { VarDirective } from '../shared/utils/var.directive'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { EPerson } from '../core/eperson/models/eperson.model'; +import { createPaginatedList, createSuccessfulRemoteDataObject$ } from '../shared/testing/utils'; +import { Store, StoreModule } from '@ngrx/store'; +import { AppState } from '../app.reducer'; +import { AuthTokenInfo } from '../core/auth/models/auth-token-info.model'; +import { EPersonDataService } from '../core/eperson/eperson-data.service'; +import { NotificationsService } from '../shared/notifications/notifications.service'; +import { authReducer } from '../core/auth/auth.reducer'; + +describe('ProfilePageComponent', () => { + let component: ProfilePageComponent; + let fixture: ComponentFixture; + + const user = Object.assign(new EPerson(), { + groups: createSuccessfulRemoteDataObject$(createPaginatedList([])) + }); + const authState = { + authenticated: true, + loaded: true, + loading: false, + authToken: new AuthTokenInfo('test_token'), + user: user + }; + + const epersonService = jasmine.createSpyObj('epersonService', { + findById: createSuccessfulRemoteDataObject$(user) + }); + const notificationsService = jasmine.createSpyObj('notificationsService', { + success: {}, + error: {}, + warning: {} + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ProfilePageComponent, VarDirective], + imports: [StoreModule.forRoot(authReducer), TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: EPersonDataService, useValue: epersonService }, + { provide: NotificationsService, useValue: notificationsService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(inject([Store], (store: Store) => { + store + .subscribe((state) => { + (state as any).core = Object.create({}); + (state as any).core.auth = authState; + }); + + fixture = TestBed.createComponent(ProfilePageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + })); + + describe('updateProfile', () => { + describe('when the metadata form returns false and the security form returns true', () => { + beforeEach(() => { + component.metadataForm = jasmine.createSpyObj('metadataForm', { + updateProfile: false + }); + component.securityForm = jasmine.createSpyObj('securityForm', { + updateSecurity: true + }); + component.updateProfile(); + }); + + it('should not display a warning', () => { + expect(notificationsService.warning).not.toHaveBeenCalled(); + }); + }); + + describe('when the metadata form returns true and the security form returns false', () => { + beforeEach(() => { + component.metadataForm = jasmine.createSpyObj('metadataForm', { + updateProfile: true + }); + component.securityForm = jasmine.createSpyObj('securityForm', { + updateSecurity: false + }); + component.updateProfile(); + }); + + it('should not display a warning', () => { + expect(notificationsService.warning).not.toHaveBeenCalled(); + }); + }); + + describe('when the metadata form returns true and the security form returns true', () => { + beforeEach(() => { + component.metadataForm = jasmine.createSpyObj('metadataForm', { + updateProfile: true + }); + component.securityForm = jasmine.createSpyObj('securityForm', { + updateSecurity: true + }); + component.updateProfile(); + }); + + it('should not display a warning', () => { + expect(notificationsService.warning).not.toHaveBeenCalled(); + }); + }); + + describe('when the metadata form returns false and the security form returns false', () => { + beforeEach(() => { + component.metadataForm = jasmine.createSpyObj('metadataForm', { + updateProfile: false + }); + component.securityForm = jasmine.createSpyObj('securityForm', { + updateSecurity: false + }); + component.updateProfile(); + }); + + it('should display a warning', () => { + expect(notificationsService.warning).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/app/profile-page/profile-page.component.ts b/src/app/profile-page/profile-page.component.ts new file mode 100644 index 0000000000..5a2736593a --- /dev/null +++ b/src/app/profile-page/profile-page.component.ts @@ -0,0 +1,84 @@ +import { Component, OnInit, ViewChild } from '@angular/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { EPerson } from '../core/eperson/models/eperson.model'; +import { select, Store } from '@ngrx/store'; +import { getAuthenticatedUser } from '../core/auth/selectors'; +import { AppState } from '../app.reducer'; +import { ProfilePageMetadataFormComponent } from './profile-page-metadata-form/profile-page-metadata-form.component'; +import { ProfilePageSecurityFormComponent } from './profile-page-security-form/profile-page-security-form.component'; +import { NotificationsService } from '../shared/notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; +import { Group } from '../core/eperson/models/group.model'; +import { RemoteData } from '../core/data/remote-data'; +import { PaginatedList } from '../core/data/paginated-list'; +import { filter, switchMap, tap } from 'rxjs/operators'; +import { EPersonDataService } from '../core/eperson/eperson-data.service'; +import { getAllSucceededRemoteData, getRemoteDataPayload, getSucceededRemoteData } from '../core/shared/operators'; +import { hasValue } from '../shared/empty.util'; +import { followLink } from '../shared/utils/follow-link-config.model'; + +@Component({ + selector: 'ds-profile-page', + templateUrl: './profile-page.component.html' +}) +/** + * Component for a user to edit their profile information + */ +export class ProfilePageComponent implements OnInit { + /** + * A reference to the metadata form component + */ + @ViewChild(ProfilePageMetadataFormComponent, { static: false }) metadataForm: ProfilePageMetadataFormComponent; + + /** + * A reference to the security form component + */ + @ViewChild(ProfilePageSecurityFormComponent, { static: false }) securityForm: ProfilePageSecurityFormComponent; + + /** + * The authenticated user + */ + user$: Observable; + + /** + * The groups the user belongs to + */ + groupsRD$: Observable>>; + + /** + * Prefix for the notification messages of this component + */ + NOTIFICATIONS_PREFIX = 'profile.notifications.'; + + constructor(private store: Store, + private notificationsService: NotificationsService, + private translate: TranslateService, + private epersonService: EPersonDataService) { + } + + ngOnInit(): void { + this.user$ = this.store.pipe( + select(getAuthenticatedUser), + filter((user: EPerson) => hasValue(user.id)), + switchMap((user: EPerson) => this.epersonService.findById(user.id, followLink('groups'))), + getAllSucceededRemoteData(), + getRemoteDataPayload() + ); + this.groupsRD$ = this.user$.pipe(switchMap((user: EPerson) => user.groups)); + } + + /** + * Fire an update on both the metadata and security forms + * Show a warning notification when no changes were made in both forms + */ + updateProfile() { + const metadataChanged = this.metadataForm.updateProfile(); + const securityChanged = this.securityForm.updateSecurity(); + if (!metadataChanged && !securityChanged) { + this.notificationsService.warning( + this.translate.instant(this.NOTIFICATIONS_PREFIX + 'warning.no-changes.title'), + this.translate.instant(this.NOTIFICATIONS_PREFIX + 'warning.no-changes.content') + ); + } + } +} diff --git a/src/app/profile-page/profile-page.module.ts b/src/app/profile-page/profile-page.module.ts new file mode 100644 index 0000000000..f40c125ff8 --- /dev/null +++ b/src/app/profile-page/profile-page.module.ts @@ -0,0 +1,23 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '../shared/shared.module'; +import { ProfilePageRoutingModule } from './profile-page-routing.module'; +import { ProfilePageComponent } from './profile-page.component'; +import { ProfilePageMetadataFormComponent } from './profile-page-metadata-form/profile-page-metadata-form.component'; +import { ProfilePageSecurityFormComponent } from './profile-page-security-form/profile-page-security-form.component'; + +@NgModule({ + imports: [ + ProfilePageRoutingModule, + CommonModule, + SharedModule + ], + declarations: [ + ProfilePageComponent, + ProfilePageMetadataFormComponent, + ProfilePageSecurityFormComponent + ] +}) +export class ProfilePageModule { + +} diff --git a/src/app/shared/auth-nav-menu/auth-nav-menu.component.html b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html index 86de30c23e..a05381fee8 100644 --- a/src/app/shared/auth-nav-menu/auth-nav-menu.component.html +++ b/src/app/shared/auth-nav-menu/auth-nav-menu.component.html @@ -1,26 +1,33 @@ diff --git a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.html b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.html index fef47b395b..ac55a211e9 100644 --- a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.html +++ b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.html @@ -1,6 +1,7 @@
{{(user$ | async)?.name}} ({{(user$ | async)?.email}}) + {{'nav.profile' | translate}} {{'nav.mydspace' | translate}} diff --git a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.ts b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.ts index e3c21b4e24..2d57a837c7 100644 --- a/src/app/shared/auth-nav-menu/user-menu/user-menu.component.ts +++ b/src/app/shared/auth-nav-menu/user-menu/user-menu.component.ts @@ -7,6 +7,7 @@ import { EPerson } from '../../../core/eperson/models/eperson.model'; import { AppState } from '../../../app.reducer'; import { getAuthenticatedUser, isAuthenticationLoading } from '../../../core/auth/selectors'; import { MYDSPACE_ROUTE } from '../../../+my-dspace-page/my-dspace-page.component'; +import { getProfileModulePath } from '../../../app-routing.module'; /** * This component represents the user nav menu. @@ -36,6 +37,11 @@ export class UserMenuComponent implements OnInit { */ public mydspaceRoute = MYDSPACE_ROUTE; + /** + * The profile page route + */ + public profileRoute = getProfileModulePath(); + constructor(private store: Store) { } 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 c37dcaedea..f8b22cc6d1 100644 --- a/src/app/shared/form/builder/parsers/field-parser.ts +++ b/src/app/shared/form/builder/parsers/field-parser.ts @@ -52,7 +52,7 @@ export abstract class FieldParser { initialCount: this.getInitArrayIndex(), notRepeatable: !this.configData.repeatable, relationshipConfig: this.configData.selectableRelationship, - required: isNotEmpty(this.configData.mandatory), + required: JSON.parse( this.configData.mandatory), submissionId: this.submissionId, metadataKey, groupFactory: () => { diff --git a/src/app/shared/item/item-versions/item-versions.component.html b/src/app/shared/item/item-versions/item-versions.component.html new file mode 100644 index 0000000000..6e93f4c7ca --- /dev/null +++ b/src/app/shared/item/item-versions/item-versions.component.html @@ -0,0 +1,47 @@ +
+
+
+

{{"item.version.history.head" | translate}}

+ + + + + + + + + + + + + + + + + + + + +
{{"item.version.history.table.version" | translate}}{{"item.version.history.table.item" | translate}}{{"item.version.history.table.editor" | translate}}{{"item.version.history.table.date" | translate}}{{"item.version.history.table.summary" | translate}}
{{version?.version}} + + {{item?.handle}} + * + + + + {{eperson?.name}} + + {{version?.created}}{{version?.summary}}
+
* {{"item.version.history.selected" | translate}}
+
+ +
+
+
diff --git a/src/app/shared/item/item-versions/item-versions.component.spec.ts b/src/app/shared/item/item-versions/item-versions.component.spec.ts new file mode 100644 index 0000000000..18fa4cf983 --- /dev/null +++ b/src/app/shared/item/item-versions/item-versions.component.spec.ts @@ -0,0 +1,121 @@ +import { ItemVersionsComponent } from './item-versions.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { VarDirective } from '../../utils/var.directive'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { Item } from '../../../core/shared/item.model'; +import { Version } from '../../../core/shared/version.model'; +import { VersionHistory } from '../../../core/shared/version-history.model'; +import { createPaginatedList, createSuccessfulRemoteDataObject$ } from '../../testing/utils'; +import { VersionHistoryDataService } from '../../../core/data/version-history-data.service'; +import { By } from '@angular/platform-browser'; + +describe('ItemVersionsComponent', () => { + let component: ItemVersionsComponent; + let fixture: ComponentFixture; + + const versionHistory = Object.assign(new VersionHistory(), { + id: '1' + }); + const version1 = Object.assign(new Version(), { + id: '1', + version: 1, + created: new Date(2020, 1, 1), + summary: 'first version', + versionhistory: createSuccessfulRemoteDataObject$(versionHistory) + }); + const version2 = Object.assign(new Version(), { + id: '2', + version: 2, + summary: 'second version', + created: new Date(2020, 1, 2), + versionhistory: createSuccessfulRemoteDataObject$(versionHistory) + }); + const versions = [version1, version2]; + versionHistory.versions = createSuccessfulRemoteDataObject$(createPaginatedList(versions)); + const item1 = Object.assign(new Item(), { + uuid: 'item-identifier-1', + handle: '123456789/1', + version: createSuccessfulRemoteDataObject$(version1) + }); + const item2 = Object.assign(new Item(), { + uuid: 'item-identifier-2', + handle: '123456789/2', + version: createSuccessfulRemoteDataObject$(version2) + }); + const items = [item1, item2]; + version1.item = createSuccessfulRemoteDataObject$(item1); + version2.item = createSuccessfulRemoteDataObject$(item2); + const versionHistoryService = jasmine.createSpyObj('versionHistoryService', { + getVersions: createSuccessfulRemoteDataObject$(createPaginatedList(versions)) + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ItemVersionsComponent, VarDirective], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: VersionHistoryDataService, useValue: versionHistoryService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ItemVersionsComponent); + component = fixture.componentInstance; + component.item = item1; + fixture.detectChanges(); + }); + + it(`should display ${versions.length} rows`, () => { + const rows = fixture.debugElement.queryAll(By.css('tbody tr')); + expect(rows.length).toBe(versions.length); + }); + + versions.forEach((version: Version, index: number) => { + const versionItem = items[index]; + + it(`should display version ${version.version} in the correct column for version ${version.id}`, () => { + const id = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-version`)); + expect(id.nativeElement.textContent).toEqual('' + version.version); + }); + + it(`should display item handle ${versionItem.handle} in the correct column for version ${version.id}`, () => { + const item = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-item`)); + expect(item.nativeElement.textContent).toContain(versionItem.handle); + }); + + // This version's item is equal to the component's item (the selected item) + // Check if the handle contains an asterisk + if (item1.uuid === versionItem.uuid) { + it('should add an asterisk to the handle of the selected item', () => { + const item = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-item`)); + expect(item.nativeElement.textContent).toContain('*'); + }); + } + + it(`should display date ${version.created} in the correct column for version ${version.id}`, () => { + const date = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-date`)); + expect(date.nativeElement.textContent).toEqual('' + version.created); + }); + + it(`should display summary ${version.summary} in the correct column for version ${version.id}`, () => { + const summary = fixture.debugElement.query(By.css(`#version-row-${version.id} .version-row-element-summary`)); + expect(summary.nativeElement.textContent).toEqual(version.summary); + }); + }); + + describe('switchPage', () => { + const page = 5; + + beforeEach(() => { + component.switchPage(page); + }); + + it('should set the option\'s currentPage to the new page', () => { + expect(component.options.currentPage).toEqual(page); + }); + }); +}); diff --git a/src/app/shared/item/item-versions/item-versions.component.ts b/src/app/shared/item/item-versions/item-versions.component.ts new file mode 100644 index 0000000000..684599d3b5 --- /dev/null +++ b/src/app/shared/item/item-versions/item-versions.component.ts @@ -0,0 +1,130 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Item } from '../../../core/shared/item.model'; +import { Version } from '../../../core/shared/version.model'; +import { RemoteData } from '../../../core/data/remote-data'; +import { Observable } from 'rxjs/internal/Observable'; +import { VersionHistory } from '../../../core/shared/version-history.model'; +import { getAllSucceededRemoteData, getRemoteDataPayload } from '../../../core/shared/operators'; +import { map, startWith, switchMap } from 'rxjs/operators'; +import { combineLatest as observableCombineLatest } from 'rxjs'; +import { PaginatedList } from '../../../core/data/paginated-list'; +import { PaginationComponentOptions } from '../../pagination/pagination-component-options.model'; +import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; +import { VersionHistoryDataService } from '../../../core/data/version-history-data.service'; +import { PaginatedSearchOptions } from '../../search/paginated-search-options.model'; +import { AlertType } from '../../alert/aletr-type'; +import { followLink } from '../../utils/follow-link-config.model'; + +@Component({ + selector: 'ds-item-versions', + templateUrl: './item-versions.component.html' +}) +/** + * Component listing all available versions of the history the provided item is a part of + */ +export class ItemVersionsComponent implements OnInit { + /** + * The item to display a version history for + */ + @Input() item: Item; + + /** + * An option to display the list of versions, even when there aren't any. + * Instead of the table, an alert will be displayed, notifying the user there are no other versions present + * for the current item. + */ + @Input() displayWhenEmpty = false; + + /** + * Whether or not to display the title + */ + @Input() displayTitle = true; + + /** + * The AlertType enumeration + * @type {AlertType} + */ + AlertTypeEnum = AlertType; + + /** + * The item's version + */ + versionRD$: Observable>; + + /** + * The item's full version history + */ + versionHistoryRD$: Observable>; + + /** + * The version history's list of versions + */ + versionsRD$: Observable>>; + + /** + * Verify if the list of versions has at least one e-person to display + * Used to hide the "Editor" column when no e-persons are present to display + */ + hasEpersons$: Observable; + + /** + * The amount of versions to display per page + */ + pageSize = 10; + + /** + * The page options to use for fetching the versions + * Start at page 1 and always use the set page size + */ + options = Object.assign(new PaginationComponentOptions(),{ + id: 'item-versions-options', + currentPage: 1, + pageSize: this.pageSize + }); + + /** + * The current page being displayed + */ + currentPage$ = new BehaviorSubject(1); + + constructor(private versionHistoryService: VersionHistoryDataService) { + } + + /** + * Initialize all observables + */ + ngOnInit(): void { + this.versionRD$ = this.item.version; + this.versionHistoryRD$ = this.versionRD$.pipe( + getAllSucceededRemoteData(), + getRemoteDataPayload(), + switchMap((version: Version) => version.versionhistory) + ); + const versionHistory$ = this.versionHistoryRD$.pipe( + getAllSucceededRemoteData(), + getRemoteDataPayload(), + ); + this.versionsRD$ = observableCombineLatest(versionHistory$, this.currentPage$).pipe( + switchMap(([versionHistory, page]: [VersionHistory, number]) => + this.versionHistoryService.getVersions(versionHistory.id, + new PaginatedSearchOptions({pagination: Object.assign({}, this.options, { currentPage: page })}), + followLink('item'), followLink('eperson'))) + ); + this.hasEpersons$ = this.versionsRD$.pipe( + getAllSucceededRemoteData(), + getRemoteDataPayload(), + map((versions: PaginatedList) => versions.page.filter((version: Version) => version.eperson !== undefined).length > 0), + startWith(false) + ); + } + + /** + * Update the current page + * @param page + */ + switchPage(page: number) { + this.options.currentPage = page; + this.currentPage$.next(page); + } + +} diff --git a/src/app/shared/item/item-versions/notice/item-versions-notice.component.html b/src/app/shared/item/item-versions/notice/item-versions-notice.component.html new file mode 100644 index 0000000000..cec0bdcb04 --- /dev/null +++ b/src/app/shared/item/item-versions/notice/item-versions-notice.component.html @@ -0,0 +1,5 @@ + + diff --git a/src/app/shared/item/item-versions/notice/item-versions-notice.component.spec.ts b/src/app/shared/item/item-versions/notice/item-versions-notice.component.spec.ts new file mode 100644 index 0000000000..ffcd1d897e --- /dev/null +++ b/src/app/shared/item/item-versions/notice/item-versions-notice.component.spec.ts @@ -0,0 +1,93 @@ +import { ItemVersionsNoticeComponent } from './item-versions-notice.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { VersionHistory } from '../../../../core/shared/version-history.model'; +import { Version } from '../../../../core/shared/version.model'; +import { createPaginatedList, createSuccessfulRemoteDataObject$ } from '../../../testing/utils'; +import { Item } from '../../../../core/shared/item.model'; +import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service'; +import { By } from '@angular/platform-browser'; + +describe('ItemVersionsNoticeComponent', () => { + let component: ItemVersionsNoticeComponent; + let fixture: ComponentFixture; + + const versionHistory = Object.assign(new VersionHistory(), { + id: '1' + }); + const firstVersion = Object.assign(new Version(), { + id: '1', + version: 1, + created: new Date(2020, 1, 1), + summary: 'first version', + versionhistory: createSuccessfulRemoteDataObject$(versionHistory) + }); + const latestVersion = Object.assign(new Version(), { + id: '2', + version: 2, + summary: 'latest version', + created: new Date(2020, 1, 2), + versionhistory: createSuccessfulRemoteDataObject$(versionHistory) + }); + const versions = [latestVersion, firstVersion]; + versionHistory.versions = createSuccessfulRemoteDataObject$(createPaginatedList(versions)); + const firstItem = Object.assign(new Item(), { + id: 'first_item_id', + uuid: 'first_item_id', + handle: '123456789/1', + version: createSuccessfulRemoteDataObject$(firstVersion) + }); + const latestItem = Object.assign(new Item(), { + id: 'latest_item_id', + uuid: 'latest_item_id', + handle: '123456789/2', + version: createSuccessfulRemoteDataObject$(latestVersion) + }); + firstVersion.item = createSuccessfulRemoteDataObject$(firstItem); + latestVersion.item = createSuccessfulRemoteDataObject$(latestItem); + const versionHistoryService = jasmine.createSpyObj('versionHistoryService', { + getVersions: createSuccessfulRemoteDataObject$(createPaginatedList(versions)) + }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ItemVersionsNoticeComponent], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])], + providers: [ + { provide: VersionHistoryDataService, useValue: versionHistoryService } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + describe('when the item is the latest version', () => { + beforeEach(() => { + initComponentWithItem(latestItem); + }); + + it('should not display a notice', () => { + const alert = fixture.debugElement.query(By.css('ds-alert')); + expect(alert).toBeNull(); + }); + }); + + describe('when the item is not the latest version', () => { + beforeEach(() => { + initComponentWithItem(firstItem); + }); + + it('should display a notice', () => { + const alert = fixture.debugElement.query(By.css('ds-alert')); + expect(alert).not.toBeNull(); + }); + }); + + function initComponentWithItem(item: Item) { + fixture = TestBed.createComponent(ItemVersionsNoticeComponent); + component = fixture.componentInstance; + component.item = item; + fixture.detectChanges(); + } +}); diff --git a/src/app/shared/item/item-versions/notice/item-versions-notice.component.ts b/src/app/shared/item/item-versions/notice/item-versions-notice.component.ts new file mode 100644 index 0000000000..c2bd316137 --- /dev/null +++ b/src/app/shared/item/item-versions/notice/item-versions-notice.component.ts @@ -0,0 +1,114 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Item } from '../../../../core/shared/item.model'; +import { PaginationComponentOptions } from '../../../pagination/pagination-component-options.model'; +import { PaginatedSearchOptions } from '../../../search/paginated-search-options.model'; +import { Observable } from 'rxjs/internal/Observable'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { VersionHistory } from '../../../../core/shared/version-history.model'; +import { Version } from '../../../../core/shared/version.model'; +import { hasValue } from '../../../empty.util'; +import { getAllSucceededRemoteData, getRemoteDataPayload } from '../../../../core/shared/operators'; +import { filter, map, startWith, switchMap } from 'rxjs/operators'; +import { followLink } from '../../../utils/follow-link-config.model'; +import { VersionHistoryDataService } from '../../../../core/data/version-history-data.service'; +import { AlertType } from '../../../alert/aletr-type'; +import { combineLatest as observableCombineLatest } from 'rxjs'; +import { getItemPageRoute } from '../../../../+item-page/item-page-routing.module'; + +@Component({ + selector: 'ds-item-versions-notice', + templateUrl: './item-versions-notice.component.html' +}) +/** + * Component for displaying a warning notice when the item is not the latest version within its version history + * The notice contains a link to the latest version's item page + */ +export class ItemVersionsNoticeComponent implements OnInit { + /** + * The item to display a version notice for + */ + @Input() item: Item; + + /** + * The item's version + */ + versionRD$: Observable>; + + /** + * The item's full version history + */ + versionHistoryRD$: Observable>; + + /** + * The latest version of the item's version history + */ + latestVersion$: Observable; + + /** + * Is the item's version equal to the latest version from the version history? + * This will determine whether or not to display a notice linking to the latest version + */ + isLatestVersion$: Observable; + + /** + * Pagination options to fetch a single version on the first page (this is the latest version in the history) + */ + latestVersionOptions = Object.assign(new PaginationComponentOptions(),{ + id: 'item-newest-version-options', + currentPage: 1, + pageSize: 1 + }); + + /** + * The AlertType enumeration + * @type {AlertType} + */ + public AlertTypeEnum = AlertType; + + constructor(private versionHistoryService: VersionHistoryDataService) { + } + + /** + * Initialize the component's observables + */ + ngOnInit(): void { + const latestVersionSearch = new PaginatedSearchOptions({pagination: this.latestVersionOptions}); + if (hasValue(this.item.version)) { + this.versionRD$ = this.item.version; + this.versionHistoryRD$ = this.versionRD$.pipe( + getAllSucceededRemoteData(), + getRemoteDataPayload(), + switchMap((version: Version) => version.versionhistory) + ); + const versionHistory$ = this.versionHistoryRD$.pipe( + getAllSucceededRemoteData(), + getRemoteDataPayload(), + ); + this.latestVersion$ = versionHistory$.pipe( + switchMap((versionHistory: VersionHistory) => + this.versionHistoryService.getVersions(versionHistory.id, latestVersionSearch, followLink('item'))), + getAllSucceededRemoteData(), + getRemoteDataPayload(), + filter((versions) => versions.page.length > 0), + map((versions) => versions.page[0]) + ); + + this.isLatestVersion$ = observableCombineLatest( + this.versionRD$.pipe(getAllSucceededRemoteData(), getRemoteDataPayload()), this.latestVersion$ + ).pipe( + map(([itemVersion, latestVersion]: [Version, Version]) => itemVersion.id === latestVersion.id), + startWith(true) + ) + } + } + + /** + * Get the item page url + * @param item The item for which the url is requested + */ + getItemPage(item: Item): string { + if (hasValue(item)) { + return getItemPageRoute(item.id); + } + } +} diff --git a/src/app/shared/log-in/container/log-in-container.component.html b/src/app/shared/log-in/container/log-in-container.component.html new file mode 100644 index 0000000000..bef6f43b66 --- /dev/null +++ b/src/app/shared/log-in/container/log-in-container.component.html @@ -0,0 +1,5 @@ + + + diff --git a/src/app/shared/log-in/container/log-in-container.component.scss b/src/app/shared/log-in/container/log-in-container.component.scss new file mode 100644 index 0000000000..0255b71dac --- /dev/null +++ b/src/app/shared/log-in/container/log-in-container.component.scss @@ -0,0 +1,21 @@ +:host ::ng-deep .card { + margin-bottom: $submission-sections-margin-bottom; + overflow: unset; +} + +.section-focus { + border-radius: $border-radius; + box-shadow: $btn-focus-box-shadow; +} + +// TODO to remove the following when upgrading @ng-bootstrap +:host ::ng-deep .card:first-of-type { + border-bottom: $card-border-width solid $card-border-color !important; + border-bottom-left-radius: $card-border-radius !important; + border-bottom-right-radius: $card-border-radius !important; +} + +:host ::ng-deep .card-header button { + box-shadow: none !important; + width: 100%; +} diff --git a/src/app/shared/log-in/container/log-in-container.component.spec.ts b/src/app/shared/log-in/container/log-in-container.component.spec.ts new file mode 100644 index 0000000000..c819b0cc8d --- /dev/null +++ b/src/app/shared/log-in/container/log-in-container.component.spec.ts @@ -0,0 +1,108 @@ +import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { StoreModule } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; + +import { LogInContainerComponent } from './log-in-container.component'; +import { authReducer } from '../../../core/auth/auth.reducer'; +import { SharedModule } from '../../shared.module'; +import { createTestComponent } from '../../testing/utils'; +import { AuthService } from '../../../core/auth/auth.service'; +import { AuthMethod } from '../../../core/auth/models/auth.method'; +import { AuthServiceStub } from '../../testing/auth-service-stub'; + +describe('LogInContainerComponent', () => { + + let component: LogInContainerComponent; + let fixture: ComponentFixture; + + const authMethod = new AuthMethod('password'); + + beforeEach(async(() => { + // refine the test module by declaring the test component + TestBed.configureTestingModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + StoreModule.forRoot(authReducer), + SharedModule, + TranslateModule.forRoot() + ], + declarations: [ + TestComponent + ], + providers: [ + {provide: AuthService, useClass: AuthServiceStub}, + LogInContainerComponent + ], + schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }) + .compileComponents(); + + })); + + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; + + // synchronous beforeEach + beforeEach(() => { + const html = ` `; + + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; + }); + + afterEach(() => { + testFixture.destroy(); + }); + + it('should create LogInContainerComponent', inject([LogInContainerComponent], (app: LogInContainerComponent) => { + + expect(app).toBeDefined(); + + })); + }); + + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(LogInContainerComponent); + component = fixture.componentInstance; + + spyOn(component, 'getAuthMethodContent').and.callThrough(); + component.authMethod = authMethod; + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + component = null; + }); + + it('should inject component properly', () => { + + component.ngOnInit(); + fixture.detectChanges(); + + expect(component.getAuthMethodContent).toHaveBeenCalled(); + + }); + + }); + +}); + +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { + + isStandalonePage = true; + +} diff --git a/src/app/shared/log-in/container/log-in-container.component.ts b/src/app/shared/log-in/container/log-in-container.component.ts new file mode 100644 index 0000000000..660e616b9d --- /dev/null +++ b/src/app/shared/log-in/container/log-in-container.component.ts @@ -0,0 +1,51 @@ +import { Component, Injector, Input, OnInit } from '@angular/core'; + +import { rendersAuthMethodType } from '../methods/log-in.methods-decorator'; +import { AuthMethod } from '../../../core/auth/models/auth.method'; + +/** + * This component represents a component container for log-in methods available. + */ +@Component({ + selector: 'ds-log-in-container', + templateUrl: './log-in-container.component.html', + styleUrls: ['./log-in-container.component.scss'] +}) +export class LogInContainerComponent implements OnInit { + + @Input() authMethod: AuthMethod; + + /** + * Injector to inject a section component with the @Input parameters + * @type {Injector} + */ + public objectInjector: Injector; + + /** + * Initialize instance variables + * + * @param {Injector} injector + */ + constructor(private injector: Injector) { + } + + /** + * Initialize all instance variables + */ + ngOnInit() { + this.objectInjector = Injector.create({ + providers: [ + { provide: 'authMethodProvider', useFactory: () => (this.authMethod), deps: [] }, + ], + parent: this.injector + }); + } + + /** + * Find the correct component based on the AuthMethod's type + */ + getAuthMethodContent(): string { + return rendersAuthMethodType(this.authMethod.authMethodType) + } + +} diff --git a/src/app/shared/log-in/log-in.component.html b/src/app/shared/log-in/log-in.component.html index fe9a506e71..8e23f00d9b 100644 --- a/src/app/shared/log-in/log-in.component.html +++ b/src/app/shared/log-in/log-in.component.html @@ -1,28 +1,13 @@ - diff --git a/src/app/shared/log-in/log-in.component.scss b/src/app/shared/log-in/log-in.component.scss index 0eda382c0a..caaeef3dc7 100644 --- a/src/app/shared/log-in/log-in.component.scss +++ b/src/app/shared/log-in/log-in.component.scss @@ -1,13 +1,3 @@ -.form-login .form-control:focus { - z-index: 2; +.login-container { + max-width: 350px; } -.form-login input[type="email"] { - margin-bottom: -1px; - border-bottom-right-radius: 0; - border-bottom-left-radius: 0; -} -.form-login input[type="password"] { - border-top-left-radius: 0; - border-top-right-radius: 0; -} - diff --git a/src/app/shared/log-in/log-in.component.spec.ts b/src/app/shared/log-in/log-in.component.spec.ts index 13f9e5369a..0be04d4ddf 100644 --- a/src/app/shared/log-in/log-in.component.spec.ts +++ b/src/app/shared/log-in/log-in.component.spec.ts @@ -1,50 +1,58 @@ -import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; -import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; - import { By } from '@angular/platform-browser'; -import { Store, StoreModule } from '@ngrx/store'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { StoreModule } from '@ngrx/store'; +import { provideMockStore } from '@ngrx/store/testing'; +import { TranslateModule } from '@ngx-translate/core'; import { LogInComponent } from './log-in.component'; -import { authReducer } from '../../core/auth/auth.reducer'; -import { EPersonMock } from '../testing/eperson-mock'; -import { EPerson } from '../../core/eperson/models/eperson.model'; -import { TranslateModule } from '@ngx-translate/core'; import { AuthService } from '../../core/auth/auth.service'; -import { AuthServiceStub } from '../testing/auth-service-stub'; -import { AppState } from '../../app.reducer'; +import { authMethodsMock, AuthServiceStub } from '../testing/auth-service-stub'; +import { createTestComponent } from '../testing/utils'; +import { SharedModule } from '../shared.module'; +import { appReducers } from '../../app.reducer'; +import { NativeWindowService } from '../../core/services/window.service'; +import { NativeWindowMockFactory } from '../mocks/mock-native-window-ref'; +import { ActivatedRoute, Router } from '@angular/router'; +import { RouterStub } from '../testing/router-stub'; +import { ActivatedRouteStub } from '../testing/active-router-stub'; describe('LogInComponent', () => { let component: LogInComponent; let fixture: ComponentFixture; - let page: Page; - let user: EPerson; - - const authState = { - authenticated: false, - loaded: false, - loading: false, + const initialState = { + core: { + auth: { + authenticated: false, + loaded: false, + loading: false, + authMethods: authMethodsMock + } + } }; - beforeEach(() => { - user = EPersonMock; - }); - beforeEach(async(() => { // refine the test module by declaring the test component TestBed.configureTestingModule({ imports: [ FormsModule, ReactiveFormsModule, - StoreModule.forRoot(authReducer), + StoreModule.forRoot(appReducers), + SharedModule, TranslateModule.forRoot() ], declarations: [ - LogInComponent + TestComponent ], providers: [ - {provide: AuthService, useClass: AuthServiceStub} + { provide: AuthService, useClass: AuthServiceStub }, + { provide: NativeWindowService, useFactory: NativeWindowMockFactory }, + { provide: Router, useValue: new RouterStub() }, + { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, + provideMockStore({ initialState }), + LogInComponent ], schemas: [ CUSTOM_ELEMENTS_SCHEMA @@ -54,75 +62,58 @@ describe('LogInComponent', () => { })); - beforeEach(inject([Store], (store: Store) => { - store - .subscribe((state) => { - (state as any).core = Object.create({}); - (state as any).core.auth = authState; - }); + describe('', () => { + let testComp: TestComponent; + let testFixture: ComponentFixture; - // create component and test fixture - fixture = TestBed.createComponent(LogInComponent); + // synchronous beforeEach + beforeEach(() => { + const html = ` `; - // get test component from the fixture - component = fixture.componentInstance; - - // create page - page = new Page(component, fixture); - - // verify the fixture is stable (no pending tasks) - fixture.whenStable().then(() => { - page.addPageElements(); + testFixture = createTestComponent(html, TestComponent) as ComponentFixture; + testComp = testFixture.componentInstance; }); - })); + afterEach(() => { + testFixture.destroy(); + }); - it('should create a FormGroup comprised of FormControls', () => { - fixture.detectChanges(); - expect(component.form instanceof FormGroup).toBe(true); + it('should create LogInComponent', inject([LogInComponent], (app: LogInComponent) => { + + expect(app).toBeDefined(); + + })); }); - it('should authenticate', () => { - fixture.detectChanges(); + describe('', () => { + beforeEach(() => { + fixture = TestBed.createComponent(LogInComponent); + component = fixture.componentInstance; - // set FormControl values - component.form.controls.email.setValue('user'); - component.form.controls.password.setValue('password'); + fixture.detectChanges(); + }); - // submit form - component.submit(); + afterEach(() => { + fixture.destroy(); + component = null; + }); - // verify Store.dispatch() is invoked - expect(page.navigateSpy.calls.any()).toBe(true, 'Store.dispatch not invoked'); + it('should render a log-in container component for each auth method available', () => { + const loginContainers = fixture.debugElement.queryAll(By.css('ds-log-in-container')); + expect(loginContainers.length).toBe(2); + + }); }); }); -/** - * I represent the DOM elements and attach spies. - * - * @class Page - */ -class Page { +// declare a test component +@Component({ + selector: 'ds-test-cmp', + template: `` +}) +class TestComponent { - public emailInput: HTMLInputElement; - public navigateSpy: jasmine.Spy; - public passwordInput: HTMLInputElement; + isStandalonePage = true; - constructor(private component: LogInComponent, private fixture: ComponentFixture) { - // use injector to get services - const injector = fixture.debugElement.injector; - const store = injector.get(Store); - - // add spies - this.navigateSpy = spyOn(store, 'dispatch'); - } - - public addPageElements() { - const emailInputSelector = 'input[formcontrolname=\'email\']'; - this.emailInput = this.fixture.debugElement.query(By.css(emailInputSelector)).nativeElement; - - const passwordInputSelector = 'input[formcontrolname=\'password\']'; - this.passwordInput = this.fixture.debugElement.query(By.css(passwordInputSelector)).nativeElement; - } } diff --git a/src/app/shared/log-in/log-in.component.ts b/src/app/shared/log-in/log-in.component.ts index b6b97230dd..92350de442 100644 --- a/src/app/shared/log-in/log-in.component.ts +++ b/src/app/shared/log-in/log-in.component.ts @@ -1,26 +1,13 @@ -import { filter, map, takeWhile } from 'rxjs/operators'; import { Component, Input, OnDestroy, OnInit } from '@angular/core'; -import { FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { select, Store } from '@ngrx/store'; import { Observable } from 'rxjs'; -import { - AuthenticateAction, - ResetAuthenticationMessagesAction -} from '../../core/auth/auth.actions'; +import { filter, takeWhile, } from 'rxjs/operators'; +import { select, Store } from '@ngrx/store'; -import { - getAuthenticationError, - getAuthenticationInfo, - isAuthenticated, - isAuthenticationLoading, -} from '../../core/auth/selectors'; +import { AuthMethod } from '../../core/auth/models/auth.method'; +import { getAuthenticationMethods, isAuthenticated, isAuthenticationLoading } from '../../core/auth/selectors'; import { CoreState } from '../../core/core.reducers'; - -import { isNotEmpty } from '../empty.util'; -import { fadeOut } from '../animations/fade'; import { AuthService } from '../../core/auth/auth.service'; -import { Router } from '@angular/router'; /** * /users/sign-in @@ -29,34 +16,21 @@ import { Router } from '@angular/router'; @Component({ selector: 'ds-log-in', templateUrl: './log-in.component.html', - styleUrls: ['./log-in.component.scss'], - animations: [fadeOut] + styleUrls: ['./log-in.component.scss'] }) -export class LogInComponent implements OnDestroy, OnInit { +export class LogInComponent implements OnInit, OnDestroy { /** - * The error if authentication fails. - * @type {Observable} - */ - public error: Observable; - - /** - * Has authentication error. + * A boolean representing if LogInComponent is in a standalone page * @type {boolean} */ - public hasError = false; + @Input() isStandalonePage: boolean; /** - * The authentication info message. - * @type {Observable} + * The list of authentication methods available + * @type {AuthMethod[]} */ - public message: Observable; - - /** - * Has authentication message. - * @type {boolean} - */ - public hasMessage = false; + public authMethods: Observable; /** * Whether user is authenticated. @@ -70,69 +44,28 @@ export class LogInComponent implements OnDestroy, OnInit { */ public loading: Observable; - /** - * The authentication form. - * @type {FormGroup} - */ - public form: FormGroup; - /** * Component state. * @type {boolean} */ private alive = true; - @Input() isStandalonePage: boolean; - - /** - * @constructor - * @param {AuthService} authService - * @param {FormBuilder} formBuilder - * @param {Router} router - * @param {Store} store - */ - constructor( - private authService: AuthService, - private formBuilder: FormBuilder, - private store: Store - ) { + constructor(private store: Store, + private authService: AuthService,) { } - /** - * Lifecycle hook that is called after data-bound properties of a directive are initialized. - * @method ngOnInit - */ - public ngOnInit() { - // set isAuthenticated - this.isAuthenticated = this.store.pipe(select(isAuthenticated)); + ngOnInit(): void { - // set formGroup - this.form = this.formBuilder.group({ - email: ['', Validators.required], - password: ['', Validators.required] - }); - - // set error - this.error = this.store.pipe(select( - getAuthenticationError), - map((error) => { - this.hasError = (isNotEmpty(error)); - return error; - }) - ); - - // set error - this.message = this.store.pipe( - select(getAuthenticationInfo), - map((message) => { - this.hasMessage = (isNotEmpty(message)); - return message; - }) + this.authMethods = this.store.pipe( + select(getAuthenticationMethods), ); // set loading this.loading = this.store.pipe(select(isAuthenticationLoading)); + // set isAuthenticated + this.isAuthenticated = this.store.pipe(select(isAuthenticated)); + // subscribe to success this.store.pipe( select(isAuthenticated), @@ -142,55 +75,11 @@ export class LogInComponent implements OnDestroy, OnInit { this.authService.redirectAfterLoginSuccess(this.isStandalonePage); } ); + } - /** - * Lifecycle hook that is called when a directive, pipe or service is destroyed. - * @method ngOnDestroy - */ - public ngOnDestroy() { + ngOnDestroy(): void { this.alive = false; } - /** - * Reset error or message. - */ - public resetErrorOrMessage() { - if (this.hasError || this.hasMessage) { - this.store.dispatch(new ResetAuthenticationMessagesAction()); - this.hasError = false; - this.hasMessage = false; - } - } - - /** - * To the registration page. - * @method register - */ - public register() { - // TODO enable after registration process is done - // this.router.navigate(['/register']); - } - - /** - * Submit the authentication form. - * @method submit - */ - public submit() { - this.resetErrorOrMessage(); - // get email and password values - const email: string = this.form.get('email').value; - const password: string = this.form.get('password').value; - - // trim values - email.trim(); - password.trim(); - - // dispatch AuthenticationAction - this.store.dispatch(new AuthenticateAction(email, password)); - - // clear form - this.form.reset(); - } - } diff --git a/src/app/shared/log-in/methods/log-in.methods-decorator.ts b/src/app/shared/log-in/methods/log-in.methods-decorator.ts new file mode 100644 index 0000000000..0614bdeb51 --- /dev/null +++ b/src/app/shared/log-in/methods/log-in.methods-decorator.ts @@ -0,0 +1,16 @@ +import { AuthMethodType } from '../../../core/auth/models/auth.method-type'; + +const authMethodsMap = new Map(); + +export function renderAuthMethodFor(authMethodType: AuthMethodType) { + return function decorator(objectElement: any) { + if (!objectElement) { + return; + } + authMethodsMap.set(authMethodType, objectElement); + }; +} + +export function rendersAuthMethodType(authMethodType: AuthMethodType) { + return authMethodsMap.get(authMethodType); +} diff --git a/src/app/shared/log-in/methods/password/log-in-password.component.html b/src/app/shared/log-in/methods/password/log-in-password.component.html new file mode 100644 index 0000000000..ddd5083d44 --- /dev/null +++ b/src/app/shared/log-in/methods/password/log-in-password.component.html @@ -0,0 +1,27 @@ +
+ + + + + + + + +
diff --git a/src/app/shared/log-in/methods/password/log-in-password.component.scss b/src/app/shared/log-in/methods/password/log-in-password.component.scss new file mode 100644 index 0000000000..0eda382c0a --- /dev/null +++ b/src/app/shared/log-in/methods/password/log-in-password.component.scss @@ -0,0 +1,13 @@ +.form-login .form-control:focus { + z-index: 2; +} +.form-login input[type="email"] { + margin-bottom: -1px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} +.form-login input[type="password"] { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + diff --git a/src/app/shared/log-in/methods/password/log-in-password.component.spec.ts b/src/app/shared/log-in/methods/password/log-in-password.component.spec.ts new file mode 100644 index 0000000000..ff65a240c8 --- /dev/null +++ b/src/app/shared/log-in/methods/password/log-in-password.component.spec.ts @@ -0,0 +1,131 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { By } from '@angular/platform-browser'; +import { Store, StoreModule } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; + +import { LogInPasswordComponent } from './log-in-password.component'; +import { EPerson } from '../../../../core/eperson/models/eperson.model'; +import { EPersonMock } from '../../../testing/eperson-mock'; +import { authReducer } from '../../../../core/auth/auth.reducer'; +import { AuthService } from '../../../../core/auth/auth.service'; +import { AuthServiceStub } from '../../../testing/auth-service-stub'; +import { AppState } from '../../../../app.reducer'; +import { AuthMethod } from '../../../../core/auth/models/auth.method'; +import { AuthMethodType } from '../../../../core/auth/models/auth.method-type'; + +describe('LogInPasswordComponent', () => { + + let component: LogInPasswordComponent; + let fixture: ComponentFixture; + let page: Page; + let user: EPerson; + + const authState = { + authenticated: false, + loaded: false, + loading: false, + }; + + beforeEach(() => { + user = EPersonMock; + }); + + beforeEach(async(() => { + // refine the test module by declaring the test component + TestBed.configureTestingModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + StoreModule.forRoot(authReducer), + TranslateModule.forRoot() + ], + declarations: [ + LogInPasswordComponent + ], + providers: [ + { provide: AuthService, useClass: AuthServiceStub }, + { provide: 'authMethodProvider', useValue: new AuthMethod(AuthMethodType.Password) } + ], + schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }) + .compileComponents(); + + })); + + beforeEach(inject([Store], (store: Store) => { + store + .subscribe((state) => { + (state as any).core = Object.create({}); + (state as any).core.auth = authState; + }); + + // create component and test fixture + fixture = TestBed.createComponent(LogInPasswordComponent); + + // get test component from the fixture + component = fixture.componentInstance; + + // create page + page = new Page(component, fixture); + + // verify the fixture is stable (no pending tasks) + fixture.whenStable().then(() => { + page.addPageElements(); + }); + + })); + + it('should create a FormGroup comprised of FormControls', () => { + fixture.detectChanges(); + expect(component.form instanceof FormGroup).toBe(true); + }); + + it('should authenticate', () => { + fixture.detectChanges(); + + // set FormControl values + component.form.controls.email.setValue('user'); + component.form.controls.password.setValue('password'); + + // submit form + component.submit(); + + // verify Store.dispatch() is invoked + expect(page.navigateSpy.calls.any()).toBe(true, 'Store.dispatch not invoked'); + }); + +}); + +/** + * I represent the DOM elements and attach spies. + * + * @class Page + */ +class Page { + + public emailInput: HTMLInputElement; + public navigateSpy: jasmine.Spy; + public passwordInput: HTMLInputElement; + + constructor(private component: LogInPasswordComponent, private fixture: ComponentFixture) { + // use injector to get services + const injector = fixture.debugElement.injector; + const store = injector.get(Store); + + // add spies + this.navigateSpy = spyOn(store, 'dispatch'); + } + + public addPageElements() { + const emailInputSelector = 'input[formcontrolname=\'email\']'; + this.emailInput = this.fixture.debugElement.query(By.css(emailInputSelector)).nativeElement; + + const passwordInputSelector = 'input[formcontrolname=\'password\']'; + this.passwordInput = this.fixture.debugElement.query(By.css(passwordInputSelector)).nativeElement; + } +} diff --git a/src/app/shared/log-in/methods/password/log-in-password.component.ts b/src/app/shared/log-in/methods/password/log-in-password.component.ts new file mode 100644 index 0000000000..8b0dd8cc04 --- /dev/null +++ b/src/app/shared/log-in/methods/password/log-in-password.component.ts @@ -0,0 +1,144 @@ +import { map } from 'rxjs/operators'; +import { Component, Inject, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; + +import { select, Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { AuthenticateAction, ResetAuthenticationMessagesAction } from '../../../../core/auth/auth.actions'; + +import { getAuthenticationError, getAuthenticationInfo, } from '../../../../core/auth/selectors'; +import { CoreState } from '../../../../core/core.reducers'; +import { isNotEmpty } from '../../../empty.util'; +import { fadeOut } from '../../../animations/fade'; +import { AuthMethodType } from '../../../../core/auth/models/auth.method-type'; +import { renderAuthMethodFor } from '../log-in.methods-decorator'; +import { AuthMethod } from '../../../../core/auth/models/auth.method'; + +/** + * /users/sign-in + * @class LogInPasswordComponent + */ +@Component({ + selector: 'ds-log-in-password', + templateUrl: './log-in-password.component.html', + styleUrls: ['./log-in-password.component.scss'], + animations: [fadeOut] +}) +@renderAuthMethodFor(AuthMethodType.Password) +export class LogInPasswordComponent implements OnInit { + + /** + * The authentication method data. + * @type {AuthMethod} + */ + public authMethod: AuthMethod; + + /** + * The error if authentication fails. + * @type {Observable} + */ + public error: Observable; + + /** + * Has authentication error. + * @type {boolean} + */ + public hasError = false; + + /** + * The authentication info message. + * @type {Observable} + */ + public message: Observable; + + /** + * Has authentication message. + * @type {boolean} + */ + public hasMessage = false; + + /** + * The authentication form. + * @type {FormGroup} + */ + public form: FormGroup; + + /** + * @constructor + * @param {AuthMethod} injectedAuthMethodModel + * @param {FormBuilder} formBuilder + * @param {Store} store + */ + constructor( + @Inject('authMethodProvider') public injectedAuthMethodModel: AuthMethod, + private formBuilder: FormBuilder, + private store: Store + ) { + this.authMethod = injectedAuthMethodModel; + } + + /** + * Lifecycle hook that is called after data-bound properties of a directive are initialized. + * @method ngOnInit + */ + public ngOnInit() { + + // set formGroup + this.form = this.formBuilder.group({ + email: ['', Validators.required], + password: ['', Validators.required] + }); + + // set error + this.error = this.store.pipe(select( + getAuthenticationError), + map((error) => { + this.hasError = (isNotEmpty(error)); + return error; + }) + ); + + // set error + this.message = this.store.pipe( + select(getAuthenticationInfo), + map((message) => { + this.hasMessage = (isNotEmpty(message)); + return message; + }) + ); + + } + + /** + * Reset error or message. + */ + public resetErrorOrMessage() { + if (this.hasError || this.hasMessage) { + this.store.dispatch(new ResetAuthenticationMessagesAction()); + this.hasError = false; + this.hasMessage = false; + } + } + + /** + * Submit the authentication form. + * @method submit + */ + public submit() { + this.resetErrorOrMessage(); + // get email and password values + const email: string = this.form.get('email').value; + const password: string = this.form.get('password').value; + + // trim values + email.trim(); + password.trim(); + + // dispatch AuthenticationAction + this.store.dispatch(new AuthenticateAction(email, password)); + + // clear form + this.form.reset(); + } + +} diff --git a/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.html b/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.html new file mode 100644 index 0000000000..713970f05b --- /dev/null +++ b/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.html @@ -0,0 +1,7 @@ + + + + + diff --git a/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.scss b/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.spec.ts b/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.spec.ts new file mode 100644 index 0000000000..29723d0f65 --- /dev/null +++ b/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.spec.ts @@ -0,0 +1,139 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { Store, StoreModule } from '@ngrx/store'; +import { TranslateModule } from '@ngx-translate/core'; + +import { EPerson } from '../../../../core/eperson/models/eperson.model'; +import { EPersonMock } from '../../../testing/eperson-mock'; +import { authReducer } from '../../../../core/auth/auth.reducer'; +import { AuthService } from '../../../../core/auth/auth.service'; +import { AuthServiceStub } from '../../../testing/auth-service-stub'; +import { AppState } from '../../../../app.reducer'; +import { AuthMethod } from '../../../../core/auth/models/auth.method'; +import { AuthMethodType } from '../../../../core/auth/models/auth.method-type'; +import { LogInShibbolethComponent } from './log-in-shibboleth.component'; +import { NativeWindowService } from '../../../../core/services/window.service'; +import { RouterStub } from '../../../testing/router-stub'; +import { ActivatedRouteStub } from '../../../testing/active-router-stub'; +import { NativeWindowMockFactory } from '../../../mocks/mock-native-window-ref'; + +describe('LogInShibbolethComponent', () => { + + let component: LogInShibbolethComponent; + let fixture: ComponentFixture; + let page: Page; + let user: EPerson; + let componentAsAny: any; + let setHrefSpy; + const shibbolethBaseUrl = 'dspace-rest.test/shibboleth?redirectUrl='; + const location = shibbolethBaseUrl + 'http://dspace-angular.test/home'; + + const authState = { + authenticated: false, + loaded: false, + loading: false, + }; + + beforeEach(() => { + user = EPersonMock; + }); + + beforeEach(async(() => { + // refine the test module by declaring the test component + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot(authReducer), + TranslateModule.forRoot() + ], + declarations: [ + LogInShibbolethComponent + ], + providers: [ + { provide: AuthService, useClass: AuthServiceStub }, + { provide: 'authMethodProvider', useValue: new AuthMethod(AuthMethodType.Shibboleth, location) }, + { provide: NativeWindowService, useFactory: NativeWindowMockFactory }, + { provide: Router, useValue: new RouterStub() }, + { provide: ActivatedRoute, useValue: new ActivatedRouteStub() }, + ], + schemas: [ + CUSTOM_ELEMENTS_SCHEMA + ] + }) + .compileComponents(); + + })); + + beforeEach(inject([Store], (store: Store) => { + store + .subscribe((state) => { + (state as any).core = Object.create({}); + (state as any).core.auth = authState; + }); + + // create component and test fixture + fixture = TestBed.createComponent(LogInShibbolethComponent); + + // get test component from the fixture + component = fixture.componentInstance; + componentAsAny = component; + + // create page + page = new Page(component, fixture); + setHrefSpy = spyOnProperty(componentAsAny._window.nativeWindow.location, 'href', 'set').and.callThrough(); + + })); + + it('should set the properly a new redirectUrl', () => { + const currentUrl = 'http://dspace-angular.test/collections/12345'; + componentAsAny._window.nativeWindow.location.href = currentUrl; + + fixture.detectChanges(); + + expect(componentAsAny.injectedAuthMethodModel.location).toBe(location); + expect(componentAsAny._window.nativeWindow.location.href).toBe(currentUrl); + + component.redirectToShibboleth(); + + expect(setHrefSpy).toHaveBeenCalledWith(shibbolethBaseUrl + currentUrl) + + }); + + it('should not set a new redirectUrl', () => { + const currentUrl = 'http://dspace-angular.test/home'; + componentAsAny._window.nativeWindow.location.href = currentUrl; + + fixture.detectChanges(); + + expect(componentAsAny.injectedAuthMethodModel.location).toBe(location); + expect(componentAsAny._window.nativeWindow.location.href).toBe(currentUrl); + + component.redirectToShibboleth(); + + expect(setHrefSpy).toHaveBeenCalledWith(shibbolethBaseUrl + currentUrl) + + }); + +}); + +/** + * I represent the DOM elements and attach spies. + * + * @class Page + */ +class Page { + + public emailInput: HTMLInputElement; + public navigateSpy: jasmine.Spy; + public passwordInput: HTMLInputElement; + + constructor(private component: LogInShibbolethComponent, private fixture: ComponentFixture) { + // use injector to get services + const injector = fixture.debugElement.injector; + const store = injector.get(Store); + + // add spies + this.navigateSpy = spyOn(store, 'dispatch'); + } + +} diff --git a/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.ts b/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.ts new file mode 100644 index 0000000000..6321e6119f --- /dev/null +++ b/src/app/shared/log-in/methods/shibboleth/log-in-shibboleth.component.ts @@ -0,0 +1,95 @@ +import { Component, Inject, OnInit, } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { select, Store } from '@ngrx/store'; + +import { renderAuthMethodFor } from '../log-in.methods-decorator'; +import { AuthMethodType } from '../../../../core/auth/models/auth.method-type'; +import { AuthMethod } from '../../../../core/auth/models/auth.method'; + +import { CoreState } from '../../../../core/core.reducers'; +import { isAuthenticated, isAuthenticationLoading } from '../../../../core/auth/selectors'; +import { RouteService } from '../../../../core/services/route.service'; +import { NativeWindowRef, NativeWindowService } from '../../../../core/services/window.service'; +import { isNotNull } from '../../../empty.util'; + +@Component({ + selector: 'ds-log-in-shibboleth', + templateUrl: './log-in-shibboleth.component.html', + styleUrls: ['./log-in-shibboleth.component.scss'], + +}) +@renderAuthMethodFor(AuthMethodType.Shibboleth) +export class LogInShibbolethComponent implements OnInit { + + /** + * The authentication method data. + * @type {AuthMethod} + */ + public authMethod: AuthMethod; + + /** + * True if the authentication is loading. + * @type {boolean} + */ + public loading: Observable; + + /** + * The shibboleth authentication location url. + * @type {string} + */ + public location: string; + + /** + * Whether user is authenticated. + * @type {Observable} + */ + public isAuthenticated: Observable; + + /** + * @constructor + * @param {AuthMethod} injectedAuthMethodModel + * @param {NativeWindowRef} _window + * @param {RouteService} route + * @param {Store} store + */ + constructor( + @Inject('authMethodProvider') public injectedAuthMethodModel: AuthMethod, + @Inject(NativeWindowService) protected _window: NativeWindowRef, + private route: RouteService, + private store: Store + ) { + this.authMethod = injectedAuthMethodModel; + } + + ngOnInit(): void { + // set isAuthenticated + this.isAuthenticated = this.store.pipe(select(isAuthenticated)); + + // set loading + this.loading = this.store.pipe(select(isAuthenticationLoading)); + + // set location + this.location = decodeURIComponent(this.injectedAuthMethodModel.location); + + } + + redirectToShibboleth() { + let newLocationUrl = this.location; + const currentUrl = this._window.nativeWindow.location.href; + const myRegexp = /\?redirectUrl=(.*)/g; + const match = myRegexp.exec(this.location); + const redirectUrl = (match && match[1]) ? match[1] : null; + + // Check whether the current page is different from the redirect url received from rest + if (isNotNull(redirectUrl) && redirectUrl !== currentUrl) { + // change the redirect url with the current page url + const newRedirectUrl = `?redirectUrl=${currentUrl}`; + newLocationUrl = this.location.replace(/\?redirectUrl=(.*)/g, newRedirectUrl); + } + + // redirect to shibboleth authentication url + this._window.nativeWindow.location.href = newLocationUrl; + } + +} diff --git a/src/app/shared/log-out/log-out.component.scss b/src/app/shared/log-out/log-out.component.scss index dcd67e092f..1514130db6 100644 --- a/src/app/shared/log-out/log-out.component.scss +++ b/src/app/shared/log-out/log-out.component.scss @@ -1 +1 @@ -@import '../log-in/log-in.component.scss'; +@import '../log-in/methods/password/log-in-password.component'; diff --git a/src/app/shared/mocks/mock-native-window-ref.ts b/src/app/shared/mocks/mock-native-window-ref.ts new file mode 100644 index 0000000000..5546bd5ccc --- /dev/null +++ b/src/app/shared/mocks/mock-native-window-ref.ts @@ -0,0 +1,21 @@ +export const MockWindow = { + location: { + _href: '', + set href(url: string) { + this._href = url; + }, + get href() { + return this._href; + } + } +}; + +export class NativeWindowRefMock { + get nativeWindow(): any { + return MockWindow; + } +} + +export function NativeWindowMockFactory() { + return new NativeWindowRefMock(); +} diff --git a/src/app/shared/mocks/mock-trucatable.service.ts b/src/app/shared/mocks/mock-trucatable.service.ts new file mode 100644 index 0000000000..0acb0b4c76 --- /dev/null +++ b/src/app/shared/mocks/mock-trucatable.service.ts @@ -0,0 +1,19 @@ +import { of as observableOf } from 'rxjs/internal/observable/of'; + +export const mockTruncatableService: any = { + /* tslint:disable:no-empty */ + isCollapsed: (id: string) => { + if (id === '1') { + return observableOf(true) + } else { + return observableOf(false); + } + }, + expand: (id: string) => { + }, + collapse: (id: string) => { + }, + toggle: (id: string) => { + } + /* tslint:enable:no-empty */ +}; diff --git a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.html b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.html index 3a8cb0cded..df8fb0eae7 100644 --- a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.html +++ b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.html @@ -2,7 +2,7 @@ {{'submission.workflow.tasks.claimed.edit' | translate}} 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/object-collection/shared/object-collection-element/abstract-listable-element.component.ts b/src/app/shared/object-collection/shared/object-collection-element/abstract-listable-element.component.ts index d27fb331de..3602f45ede 100644 --- a/src/app/shared/object-collection/shared/object-collection-element/abstract-listable-element.component.ts +++ b/src/app/shared/object-collection/shared/object-collection-element/abstract-listable-element.component.ts @@ -1,6 +1,8 @@ import { Component, Input } from '@angular/core'; import { ListableObject } from '../listable-object.model'; import { CollectionElementLinkType } from '../../collection-element-link.type'; +import { Context } from '../../../../core/shared/context.model'; +import { ViewMode } from '../../../../core/shared/view-mode.model'; @Component({ selector: 'ds-abstract-object-element', @@ -22,8 +24,23 @@ export class AbstractListableElementComponent { */ @Input() listID: string; + /** + * The index of this element + */ + @Input() index: number; + /** * The available link types */ linkTypes = CollectionElementLinkType; + + /** + * The available view modes + */ + viewModes = ViewMode; + + /** + * The available contexts + */ + contexts = Context; } diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.html b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.html index d4ecaaa332..a03d8c96fe 100644 --- a/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.html +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.html @@ -1,8 +1,10 @@ - - + + + - + + diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.spec.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.spec.ts index 074ce56f92..bdeba1a894 100644 --- a/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.spec.ts +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.spec.ts @@ -11,6 +11,9 @@ import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspa import { WorkflowItem } from '../../../../core/submission/models/workflowitem.model'; import { createSuccessfulRemoteDataObject } from '../../../testing/utils'; import { ClaimedTaskSearchResult } from '../../../object-collection/shared/claimed-task-search-result.model'; +import { VarDirective } from '../../../utils/var.directive'; +import { getMockLinkService } from '../../../mocks/mock-link-service'; +import { LinkService } from '../../../../core/cache/builders/link.service'; let component: ClaimedTaskSearchResultDetailElementComponent; let fixture: ComponentFixture; @@ -53,12 +56,16 @@ const rdItem = createSuccessfulRemoteDataObject(item); const workflowitem = Object.assign(new WorkflowItem(), { item: observableOf(rdItem) }); const rdWorkflowitem = createSuccessfulRemoteDataObject(workflowitem); mockResultObject.indexableObject = Object.assign(new ClaimedTask(), { workflowitem: observableOf(rdWorkflowitem) }); +const linkService = getMockLinkService(); describe('ClaimedTaskSearchResultDetailElementComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ imports: [NoopAnimationsModule], - declarations: [ClaimedTaskSearchResultDetailElementComponent], + declarations: [ClaimedTaskSearchResultDetailElementComponent, VarDirective], + providers: [ + { provide: LinkService, useValue: linkService } + ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(ClaimedTaskSearchResultDetailElementComponent, { set: { changeDetection: ChangeDetectionStrategy.Default } @@ -75,8 +82,12 @@ describe('ClaimedTaskSearchResultDetailElementComponent', () => { fixture.detectChanges(); }); - it('should init item properly', () => { - expect(component.workflowitem).toEqual(workflowitem); + it('should init workflowitem properly', (done) => { + component.workflowitemRD$.subscribe((workflowitemRD) => { + expect(linkService.resolveLink).toHaveBeenCalled(); + expect(workflowitemRD.payload).toEqual(workflowitem); + done(); + }); }); it('should have properly status', () => { diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.ts b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.ts index 72ffca4b98..4647a4d4a7 100644 --- a/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.ts +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/claimed-task-search-result/claimed-task-search-result-detail-element.component.ts @@ -1,17 +1,17 @@ import { Component } from '@angular/core'; import { Observable } from 'rxjs'; -import { find } from 'rxjs/operators'; import { RemoteData } from '../../../../core/data/remote-data'; import { ViewMode } from '../../../../core/shared/view-mode.model'; -import { isNotUndefined } from '../../../empty.util'; import { WorkflowItem } from '../../../../core/submission/models/workflowitem.model'; import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; import { SearchResultDetailElementComponent } from '../search-result-detail-element.component'; import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; import { listableObjectComponent } from '../../../object-collection/shared/listable-object/listable-object.decorator'; import { ClaimedTaskSearchResult } from '../../../object-collection/shared/claimed-task-search-result.model'; +import { followLink } from '../../../utils/follow-link-config.model'; +import { LinkService } from '../../../../core/cache/builders/link.service'; /** * This component renders claimed task object for the search result in the detail view. @@ -38,25 +38,22 @@ export class ClaimedTaskSearchResultDetailElementComponent extends SearchResultD /** * The workflowitem object that belonging to the result object */ - public workflowitem: WorkflowItem; + public workflowitemRD$: Observable>; + + constructor(protected linkService: LinkService) { + super(); + } /** * Initialize all instance variables */ ngOnInit() { super.ngOnInit(); - this.initWorkflowItem(this.dso.workflowitem as Observable>); - } - - /** - * Retrieve workflow item from result object - */ - initWorkflowItem(wfi$: Observable>) { - wfi$.pipe( - find((rd: RemoteData) => (rd.hasSucceeded && isNotUndefined(rd.payload))) - ).subscribe((rd: RemoteData) => { - this.workflowitem = rd.payload; - }); + this.linkService.resolveLink(this.dso, followLink('workflowitem', null, true, + followLink('item', null, true, followLink('bundles')), + followLink('submitter') + )); + this.workflowitemRD$ = this.dso.workflowitem as Observable>; } } diff --git a/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.html b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.html index ab2c24c435..cbc3f9ccfb 100644 --- a/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.html +++ b/src/app/shared/object-detail/my-dspace-result-detail-element/item-detail-preview/item-detail-preview.component.html @@ -10,9 +10,9 @@
- + - +
+
diff --git a/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts b/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts index c14e3f6df1..de19f5b74a 100644 --- a/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts +++ b/src/app/shared/object-grid/search-result-grid-element/collection-search-result/collection-search-result-grid-element.component.spec.ts @@ -6,6 +6,7 @@ import { Store } from '@ngrx/store'; import { of as observableOf } from 'rxjs'; import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../../../../core/cache/object-cache.service'; +import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; import { CommunityDataService } from '../../../../core/data/community-data.service'; import { DefaultChangeAnalyzer } from '../../../../core/data/default-change-analyzer.service'; import { DSOChangeAnalyzer } from '../../../../core/data/dso-change-analyzer.service'; @@ -62,6 +63,7 @@ describe('CollectionSearchResultGridElementComponent', () => { { provide: UUIDService, useValue: {} }, { provide: Store, useValue: {} }, { provide: RemoteDataBuildService, useValue: {} }, + { provide: BitstreamDataService, useValue: {} }, { provide: CommunityDataService, useValue: {} }, { provide: HALEndpointService, useValue: {} }, { provide: NotificationsService, useValue: {} }, diff --git a/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.html b/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.html index d0a9aa700e..8d5f288498 100644 --- a/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.html +++ b/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.html @@ -1,17 +1,18 @@
- - - - - + + + + + -
-

{{dso.name}}

-

{{dso.shortDescription}}

-
- View +
+

{{dso.name}}

+

{{dso.shortDescription}}

+
+ View +
-
+
diff --git a/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.spec.ts b/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.spec.ts index 0ea72b52d5..b97c574970 100644 --- a/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.spec.ts +++ b/src/app/shared/object-grid/search-result-grid-element/community-search-result/community-search-result-grid-element.component.spec.ts @@ -6,6 +6,7 @@ import { Store } from '@ngrx/store'; import { of as observableOf } from 'rxjs'; import { RemoteDataBuildService } from '../../../../core/cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../../../../core/cache/object-cache.service'; +import { BitstreamDataService } from '../../../../core/data/bitstream-data.service'; import { CommunityDataService } from '../../../../core/data/community-data.service'; import { DefaultChangeAnalyzer } from '../../../../core/data/default-change-analyzer.service'; import { DSOChangeAnalyzer } from '../../../../core/data/dso-change-analyzer.service'; @@ -62,6 +63,7 @@ describe('CommunitySearchResultGridElementComponent', () => { { provide: UUIDService, useValue: {} }, { provide: Store, useValue: {} }, { provide: RemoteDataBuildService, useValue: {} }, + { provide: BitstreamDataService, useValue: {} }, { provide: CommunityDataService, useValue: {} }, { provide: HALEndpointService, useValue: {} }, { provide: NotificationsService, useValue: {} }, diff --git a/src/app/shared/object-grid/search-result-grid-element/item-search-result/publication/publication-search-result-grid-element.component.html b/src/app/shared/object-grid/search-result-grid-element/item-search-result/publication/publication-search-result-grid-element.component.html index 41c16c6eab..ec0b792e34 100644 --- a/src/app/shared/object-grid/search-result-grid-element/item-search-result/publication/publication-search-result-grid-element.component.html +++ b/src/app/shared/object-grid/search-result-grid-element/item-search-result/publication/publication-search-result-grid-element.component.html @@ -1,41 +1,44 @@ - -
- -
- - -
-
- -
- - -
-
-
- - -

-
-

- - {{firstMetadataValue('dc.date.issued')}} - , - - - -

-

- - - -

-
- View -
-
-
-
+
+ + + +
+ + +
+
+ +
+ + +
+
+
+ + +

+
+

+ + {{firstMetadataValue('dc.date.issued')}} + , + + + +

+

+ + + +

+
+ View +
+
+
+ +
+ diff --git a/src/app/shared/object-grid/search-result-grid-element/item-search-result/publication/publication-search-result-grid-element.component.ts b/src/app/shared/object-grid/search-result-grid-element/item-search-result/publication/publication-search-result-grid-element.component.ts index 76618f18f2..c96e73d365 100644 --- a/src/app/shared/object-grid/search-result-grid-element/item-search-result/publication/publication-search-result-grid-element.component.ts +++ b/src/app/shared/object-grid/search-result-grid-element/item-search-result/publication/publication-search-result-grid-element.component.ts @@ -7,6 +7,7 @@ import { Item } from '../../../../../core/shared/item.model'; import { ItemSearchResult } from '../../../../object-collection/shared/item-search-result.model'; @listableObjectComponent('PublicationSearchResult', ViewMode.GridElement) +@listableObjectComponent(ItemSearchResult, ViewMode.GridElement) @Component({ selector: 'ds-publication-search-result-grid-element', styleUrls: ['./publication-search-result-grid-element.component.scss'], diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.html b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.html index 5ec4c9a5b5..b35a4f8741 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.html @@ -1,7 +1,10 @@ - + + + + + - diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.spec.ts index cce134f806..03cc46725b 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.spec.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.spec.ts @@ -12,6 +12,9 @@ import { WorkflowItem } from '../../../../core/submission/models/workflowitem.mo import { createSuccessfulRemoteDataObject } from '../../../testing/utils'; import { ClaimedTaskSearchResult } from '../../../object-collection/shared/claimed-task-search-result.model'; import { TruncatableService } from '../../../truncatable/truncatable.service'; +import { VarDirective } from '../../../utils/var.directive'; +import { LinkService } from '../../../../core/cache/builders/link.service'; +import { getMockLinkService } from '../../../mocks/mock-link-service'; let component: ClaimedSearchResultListElementComponent; let fixture: ComponentFixture; @@ -54,14 +57,16 @@ const rdItem = createSuccessfulRemoteDataObject(item); const workflowitem = Object.assign(new WorkflowItem(), { item: observableOf(rdItem) }); const rdWorkflowitem = createSuccessfulRemoteDataObject(workflowitem); mockResultObject.indexableObject = Object.assign(new ClaimedTask(), { workflowitem: observableOf(rdWorkflowitem) }); +const linkService = getMockLinkService(); describe('ClaimedSearchResultListElementComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ imports: [NoopAnimationsModule], - declarations: [ClaimedSearchResultListElementComponent], + declarations: [ClaimedSearchResultListElementComponent, VarDirective], providers: [ { provide: TruncatableService, useValue: {} }, + { provide: LinkService, useValue: linkService } ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(ClaimedSearchResultListElementComponent, { @@ -79,8 +84,12 @@ describe('ClaimedSearchResultListElementComponent', () => { fixture.detectChanges(); }); - it('should init item properly', () => { - expect(component.workflowitem).toEqual(workflowitem); + it('should init workflowitem properly', (done) => { + component.workflowitemRD$.subscribe((workflowitemRD) => { + expect(linkService.resolveLink).toHaveBeenCalled(); + expect(workflowitemRD.payload).toEqual(workflowitem); + done(); + }); }); it('should have properly status', () => { diff --git a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts index 35371f40aa..cb46e25282 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/claimed-search-result/claimed-search-result-list-element.component.ts @@ -2,17 +2,18 @@ import { Component } from '@angular/core'; import { Location, LocationStrategy, PathLocationStrategy } from '@angular/common'; import { Observable } from 'rxjs'; -import { find } from 'rxjs/operators'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { RemoteData } from '../../../../core/data/remote-data'; -import { isNotUndefined } from '../../../empty.util'; import { WorkflowItem } from '../../../../core/submission/models/workflowitem.model'; import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; import { listableObjectComponent } from '../../../object-collection/shared/listable-object/listable-object.decorator'; import { ClaimedTaskSearchResult } from '../../../object-collection/shared/claimed-task-search-result.model'; import { SearchResultListElementComponent } from '../../search-result-list-element/search-result-list-element.component'; +import { followLink } from '../../../utils/follow-link-config.model'; +import { LinkService } from '../../../../core/cache/builders/link.service'; +import { TruncatableService } from '../../../truncatable/truncatable.service'; /** * This component renders claimed task object for the search result in the list view. @@ -40,24 +41,23 @@ export class ClaimedSearchResultListElementComponent extends SearchResultListEle /** * The workflowitem object that belonging to the result object */ - public workflowitem: WorkflowItem; + public workflowitemRD$: Observable>; + + constructor( + protected linkService: LinkService, + protected truncatableService: TruncatableService + ) { + super(truncatableService); + } /** * Initialize all instance variables */ ngOnInit() { super.ngOnInit(); - this.initWorkflowItem(this.dso.workflowitem as Observable>); - } - - /** - * Retrieve workflowitem from result object - */ - initWorkflowItem(wfi$: Observable>) { - wfi$.pipe( - find((rd: RemoteData) => (rd.hasSucceeded && isNotUndefined(rd.payload))) - ).subscribe((rd: RemoteData) => { - this.workflowitem = rd.payload; - }); + this.linkService.resolveLink(this.dso, followLink('workflowitem', null, true, + followLink('item'), followLink('submitter') + )); + this.workflowitemRD$ = this.dso.workflowitem as Observable>; } } diff --git a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.html b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.html index 5f0f1bb6d4..9358e35bed 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.html @@ -1,7 +1,9 @@ - + + - + + diff --git a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.spec.ts index 119870cf9c..39f567bb2e 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.spec.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.spec.ts @@ -12,6 +12,9 @@ import { WorkflowItem } from '../../../../core/submission/models/workflowitem.mo import { createSuccessfulRemoteDataObject } from '../../../testing/utils'; import { PoolTaskSearchResult } from '../../../object-collection/shared/pool-task-search-result.model'; import { TruncatableService } from '../../../truncatable/truncatable.service'; +import { VarDirective } from '../../../utils/var.directive'; +import { LinkService } from '../../../../core/cache/builders/link.service'; +import { getMockLinkService } from '../../../mocks/mock-link-service'; let component: PoolSearchResultListElementComponent; let fixture: ComponentFixture; @@ -54,14 +57,16 @@ const rdItem = createSuccessfulRemoteDataObject(item); const workflowitem = Object.assign(new WorkflowItem(), { item: observableOf(rdItem) }); const rdWorkflowitem = createSuccessfulRemoteDataObject(workflowitem); mockResultObject.indexableObject = Object.assign(new PoolTask(), { workflowitem: observableOf(rdWorkflowitem) }); +const linkService = getMockLinkService(); describe('PoolSearchResultListElementComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ imports: [NoopAnimationsModule], - declarations: [PoolSearchResultListElementComponent], + declarations: [PoolSearchResultListElementComponent, VarDirective], providers: [ { provide: TruncatableService, useValue: {} }, + { provide: LinkService, useValue: linkService } ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(PoolSearchResultListElementComponent, { @@ -79,8 +84,12 @@ describe('PoolSearchResultListElementComponent', () => { fixture.detectChanges(); }); - it('should init item properly', () => { - expect(component.workflowitem).toEqual(workflowitem); + it('should init workflowitem properly', (done) => { + component.workflowitemRD$.subscribe((workflowitemRD) => { + expect(linkService.resolveLink).toHaveBeenCalled(); + expect(workflowitemRD.payload).toEqual(workflowitem); + done(); + }); }); it('should have properly status', () => { diff --git a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts index b34e23c3e6..8ab00f4b9b 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/pool-search-result/pool-search-result-list-element.component.ts @@ -1,11 +1,9 @@ import { Component, OnInit } from '@angular/core'; import { Observable } from 'rxjs'; -import { find } from 'rxjs/operators'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { RemoteData } from '../../../../core/data/remote-data'; -import { isNotUndefined } from '../../../empty.util'; import { WorkflowItem } from '../../../../core/submission/models/workflowitem.model'; import { PoolTask } from '../../../../core/tasks/models/pool-task-object.model'; import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; @@ -13,6 +11,8 @@ import { listableObjectComponent } from '../../../object-collection/shared/lista import { PoolTaskSearchResult } from '../../../object-collection/shared/pool-task-search-result.model'; import { SearchResultListElementComponent } from '../../search-result-list-element/search-result-list-element.component'; import { TruncatableService } from '../../../truncatable/truncatable.service'; +import { followLink } from '../../../utils/follow-link-config.model'; +import { LinkService } from '../../../../core/cache/builders/link.service'; /** * This component renders pool task object for the search result in the list view. @@ -39,14 +39,17 @@ export class PoolSearchResultListElementComponent extends SearchResultListElemen /** * The workflowitem object that belonging to the result object */ - public workflowitem: WorkflowItem; + public workflowitemRD$: Observable>; /** * The index of this list element */ public index: number; - constructor(protected truncatableService: TruncatableService) { + constructor( + protected linkService: LinkService, + protected truncatableService: TruncatableService + ) { super(truncatableService); } @@ -55,17 +58,9 @@ export class PoolSearchResultListElementComponent extends SearchResultListElemen */ ngOnInit() { super.ngOnInit(); - this.initWorkflowItem(this.dso.workflowitem as Observable>); - } - - /** - * Retrieve workflowitem from result object - */ - initWorkflowItem(wfi$: Observable>) { - wfi$.pipe( - find((rd: RemoteData) => (rd.hasSucceeded && isNotUndefined(rd.payload))) - ).subscribe((rd: RemoteData) => { - this.workflowitem = rd.payload; - }); + this.linkService.resolveLink(this.dso, followLink('workflowitem', null, true, + followLink('item'), followLink('submitter') + )); + this.workflowitemRD$ = this.dso.workflowitem as Observable>; } } diff --git a/src/app/shared/pagination/pagination.component.ts b/src/app/shared/pagination/pagination.component.ts index 9c378d1aff..04309b6f9f 100644 --- a/src/app/shared/pagination/pagination.component.ts +++ b/src/app/shared/pagination/pagination.component.ts @@ -99,6 +99,13 @@ export class PaginationComponent implements OnDestroy, OnInit { */ @Input() public hidePagerWhenSinglePage = true; + /** + * Option for disabling updating and reading route parameters on pagination changes + * In other words, changing pagination won't add or update the url parameters on the current page, and the url + * parameters won't affect the pagination of this component + */ + @Input() public disableRouteParameterUpdate = false; + /** * Current page. */ @@ -173,20 +180,35 @@ export class PaginationComponent implements OnDestroy, OnInit { this.checkConfig(this.paginationOptions); this.initializeConfig(); // Listen to changes - this.subs.push(this.route.queryParams - .subscribe((queryParams) => { - if (this.isEmptyPaginationParams(queryParams)) { - this.initializeConfig(queryParams); + if (!this.disableRouteParameterUpdate) { + this.subs.push(this.route.queryParams + .subscribe((queryParams) => { + this.initializeParams(queryParams); + })); + } + } + + /** + * Initialize the route and current parameters + * This method will fix any invalid or missing parameters + * @param params + */ + private initializeParams(params) { + if (this.isEmptyPaginationParams(params)) { + this.initializeConfig(params); + } else { + this.currentQueryParams = params; + const fixedProperties = this.validateParams(params); + if (isNotEmpty(fixedProperties)) { + if (!this.disableRouteParameterUpdate) { + this.fixRoute(fixedProperties); } else { - this.currentQueryParams = queryParams; - const fixedProperties = this.validateParams(queryParams); - if (isNotEmpty(fixedProperties)) { - this.fixRoute(fixedProperties); - } else { - this.setFields(); - } + this.initializeParams(fixedProperties); } - })); + } else { + this.setFields(); + } + } } private fixRoute(fixedProperties) { @@ -247,7 +269,7 @@ export class PaginationComponent implements OnDestroy, OnInit { * The page being navigated to. */ public doPageChange(page: number) { - this.updateRoute({ pageId: this.id, page: page.toString() }); + this.updateParams(Object.assign({}, this.currentQueryParams, { pageId: this.id, page: page.toString() })); } /** @@ -257,7 +279,7 @@ export class PaginationComponent implements OnDestroy, OnInit { * The page size being navigated to. */ public doPageSizeChange(pageSize: number) { - this.updateRoute({ pageId: this.id, page: 1, pageSize: pageSize }); + this.updateParams(Object.assign({}, this.currentQueryParams,{ pageId: this.id, page: 1, pageSize: pageSize })); } /** @@ -267,7 +289,7 @@ export class PaginationComponent implements OnDestroy, OnInit { * The sort direction being navigated to. */ public doSortDirectionChange(sortDirection: SortDirection) { - this.updateRoute({ pageId: this.id, page: 1, sortDirection: sortDirection }); + this.updateParams(Object.assign({}, this.currentQueryParams,{ pageId: this.id, page: 1, sortDirection: sortDirection })); } /** @@ -277,7 +299,7 @@ export class PaginationComponent implements OnDestroy, OnInit { * The sort field being navigated to. */ public doSortFieldChange(field: string) { - this.updateRoute({ pageId: this.id, page: 1, sortField: field }); + this.updateParams(Object.assign(this.currentQueryParams,{ pageId: this.id, page: 1, sortField: field })); } /** @@ -347,6 +369,20 @@ export class PaginationComponent implements OnDestroy, OnInit { }) } + /** + * Update the current query params and optionally update the route + * @param params + */ + private updateParams(params: {}) { + if (isNotEmpty(difference(params, this.currentQueryParams))) { + if (!this.disableRouteParameterUpdate) { + this.updateRoute(params); + } else { + this.initializeParams(params); + } + } + } + /** * Method to update the route parameters */ diff --git a/src/app/shared/search/paginated-search-options.model.spec.ts b/src/app/shared/search/paginated-search-options.model.spec.ts index 2702c3ff01..1739fd54fb 100644 --- a/src/app/shared/search/paginated-search-options.model.spec.ts +++ b/src/app/shared/search/paginated-search-options.model.spec.ts @@ -27,9 +27,9 @@ describe('PaginatedSearchOptions', () => { 'query=search query&' + 'scope=0fde1ecb-82cc-425a-b600-ac3576d76b47&' + 'dsoType=ITEM&' + - 'f.test=value,query&' + - 'f.example=another value,query&' + - 'f.example=second value,query' + 'f.test=value&' + + 'f.example=another value&' + + 'f.example=second value' ); }); diff --git a/src/app/shared/search/search-filter.model.ts b/src/app/shared/search/search-filter.model.ts index 9e93bafed8..ee55bec242 100644 --- a/src/app/shared/search/search-filter.model.ts +++ b/src/app/shared/search/search-filter.model.ts @@ -1,7 +1,6 @@ /** * Represents a search filter */ -import { hasValue } from '../empty.util'; export class SearchFilter { key: string; @@ -11,10 +10,6 @@ export class SearchFilter { constructor(key: string, values: string[], operator?: string) { this.key = key; this.values = values; - if (hasValue(operator)) { - this.operator = operator; - } else { - this.operator = 'query'; - } + this.operator = operator; } } diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html index 9441081661..cf4876e34f 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.html @@ -2,7 +2,9 @@ [routerLink]="[searchLink]" [queryParams]="addQueryParams" queryParamsHandling="merge"> - {{filterValue.value}} + + {{ 'search.filters.' + filterConfig.name + '.' + filterValue.value | translate: {default: filterValue.value} }} + {{filterValue.count}} diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html index 5198433207..a27a5d3d86 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.html @@ -2,5 +2,7 @@ [routerLink]="[searchLink]" [queryParams]="removeQueryParams" queryParamsHandling="merge"> - {{selectedValue.label}} + + {{ 'search.filters.' + filterConfig.name + '.' + selectedValue.value | translate: {default: selectedValue.value} }} + diff --git a/src/app/shared/search/search-labels/search-label/search-label.component.html b/src/app/shared/search/search-labels/search-label/search-label.component.html index 391efcb763..bffb7f9329 100644 --- a/src/app/shared/search/search-labels/search-label/search-label.component.html +++ b/src/app/shared/search/search-labels/search-label/search-label.component.html @@ -1,6 +1,6 @@ - {{('search.filters.applied.' + key) | translate}}: {{normalizeFilterValue(value)}} + {{('search.filters.applied.' + key) | translate}}: {{'search.filters.' + filterName + '.' + value | translate: {default: normalizeFilterValue(value)} }} × - \ No newline at end of file + diff --git a/src/app/shared/search/search-labels/search-label/search-label.component.ts b/src/app/shared/search/search-labels/search-label/search-label.component.ts index 956b5b81de..2203f73a75 100644 --- a/src/app/shared/search/search-labels/search-label/search-label.component.ts +++ b/src/app/shared/search/search-labels/search-label/search-label.component.ts @@ -22,6 +22,11 @@ export class SearchLabelComponent implements OnInit { searchLink: string; removeParameters: Observable; + /** + * The name of the filter without the f. prefix + */ + filterName: string; + /** * Initialize the instance variable */ @@ -33,6 +38,7 @@ export class SearchLabelComponent implements OnInit { ngOnInit(): void { this.searchLink = this.getSearchLink(); this.removeParameters = this.getRemoveParams(); + this.filterName = this.getFilterName(); } /** @@ -74,4 +80,8 @@ export class SearchLabelComponent implements OnInit { const pattern = /,authority*$/g; return value.replace(pattern, ''); } + + private getFilterName(): string { + return this.key.startsWith('f.') ? this.key.substring(2) : this.key; + } } diff --git a/src/app/shared/search/search-options.model.spec.ts b/src/app/shared/search/search-options.model.spec.ts index 3b047b578f..3195ec3660 100644 --- a/src/app/shared/search/search-options.model.spec.ts +++ b/src/app/shared/search/search-options.model.spec.ts @@ -21,9 +21,9 @@ describe('SearchOptions', () => { 'query=search query&' + 'scope=0fde1ecb-82cc-425a-b600-ac3576d76b47&' + 'dsoType=ITEM&' + - 'f.test=value,query&' + - 'f.example=another value,query&' + - 'f.example=second value,query' + 'f.test=value&' + + 'f.example=another value&' + + 'f.example=second value' ); }); diff --git a/src/app/shared/search/search-options.model.ts b/src/app/shared/search/search-options.model.ts index a8b115abd3..7d5f4dd207 100644 --- a/src/app/shared/search/search-options.model.ts +++ b/src/app/shared/search/search-options.model.ts @@ -50,7 +50,7 @@ export class SearchOptions { if (isNotEmpty(this.filters)) { this.filters.forEach((filter: SearchFilter) => { filter.values.forEach((value) => { - const filterValue = value.includes(',') ? `${value}` : `${value},${filter.operator}`; + const filterValue = value.includes(',') ? `${value}` : value + (filter.operator ? ',' + filter.operator : ''); args.push(`${filter.key}=${filterValue}`) }); }); diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index c6180e3a3b..698d9c936c 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -6,7 +6,7 @@ import { NouisliderModule } from 'ng2-nouislider'; import { NgbDatepickerModule, NgbModule, NgbTimepickerModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap'; -import { TranslateModule } from '@ngx-translate/core'; +import { MissingTranslationHandler, TranslateModule } from '@ngx-translate/core'; import { NgxPaginationModule } from 'ngx-pagination'; import { PublicationListElementComponent } from './object-list/item-list-element/item-types/publication/publication-list-element.component'; @@ -42,13 +42,15 @@ import { SearchResultGridElementComponent } from './object-grid/search-result-gr import { ViewModeSwitchComponent } from './view-mode-switch/view-mode-switch.component'; import { GridThumbnailComponent } from './object-grid/grid-thumbnail/grid-thumbnail.component'; import { VarDirective } from './utils/var.directive'; -import { LogInComponent } from './log-in/log-in.component'; import { AuthNavMenuComponent } from './auth-nav-menu/auth-nav-menu.component'; import { LogOutComponent } from './log-out/log-out.component'; import { FormComponent } from './form/form.component'; import { DsDynamicTypeaheadComponent } from './form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component'; import { DsDynamicScrollableDropdownComponent } from './form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component'; -import { DsDynamicFormControlContainerComponent, dsDynamicFormControlMapFn } from './form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component'; +import { + DsDynamicFormControlContainerComponent, + dsDynamicFormControlMapFn +} from './form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component'; import { DsDynamicFormComponent } from './form/builder/ds-dynamic-form-ui/ds-dynamic-form.component'; import { DYNAMIC_FORM_CONTROL_MAP_FN, DynamicFormsCoreModule } from '@ng-dynamic-forms/core'; import { DynamicFormsNGBootstrapUIModule } from '@ng-dynamic-forms/ui-ng-bootstrap'; @@ -176,7 +178,14 @@ import { ExternalSourceEntryImportModalComponent } from './form/builder/ds-dynam import { ImportableListItemControlComponent } from './object-collection/shared/importable-list-item-control/importable-list-item-control.component'; import { DragDropModule } from '@angular/cdk/drag-drop'; import { ExistingMetadataListElementComponent } from './form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component'; +import { ItemVersionsComponent } from './item/item-versions/item-versions.component'; import { SortablejsModule } from 'ngx-sortablejs'; +import { LogInContainerComponent } from './log-in/container/log-in-container.component'; +import { LogInShibbolethComponent } from './log-in/methods/shibboleth/log-in-shibboleth.component'; +import { LogInPasswordComponent } from './log-in/methods/password/log-in-password.component'; +import { LogInComponent } from './log-in/log-in.component'; +import { MissingTranslationHelper } from './translate/missing-translation.helper'; +import { ItemVersionsNoticeComponent } from './item/item-versions/notice/item-versions-notice.component'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -194,7 +203,6 @@ const MODULES = [ NgxPaginationModule, ReactiveFormsModule, RouterModule, - TranslateModule, NouisliderModule, MomentModule, TextMaskModule, @@ -203,7 +211,11 @@ const MODULES = [ ]; const ROOT_MODULES = [ - TooltipModule.forRoot() + TranslateModule.forChild({ + missingTranslationHandler: { provide: MissingTranslationHandler, useClass: MissingTranslationHelper }, + useDefaultLang: true + }), + TooltipModule.forRoot(), ]; const PIPES = [ @@ -339,7 +351,13 @@ const COMPONENTS = [ SelectableListItemControlComponent, ExternalSourceEntryImportModalComponent, ImportableListItemControlComponent, - ExistingMetadataListElementComponent + ExistingMetadataListElementComponent, + LogInShibbolethComponent, + LogInPasswordComponent, + LogInContainerComponent, + ItemVersionsComponent, + PublicationSearchResultListElementComponent, + ItemVersionsNoticeComponent ]; const ENTRY_COMPONENTS = [ @@ -402,7 +420,11 @@ const ENTRY_COMPONENTS = [ DsDynamicLookupRelationSearchTabComponent, DsDynamicLookupRelationSelectionTabComponent, DsDynamicLookupRelationExternalSourceTabComponent, - ExternalSourceEntryImportModalComponent + ExternalSourceEntryImportModalComponent, + LogInPasswordComponent, + LogInShibbolethComponent, + ItemVersionsComponent, + ItemVersionsNoticeComponent ]; const SHARED_ITEM_PAGE_COMPONENTS = [ @@ -435,8 +457,8 @@ const DIRECTIVES = [ @NgModule({ imports: [ + ...ROOT_MODULES, ...MODULES, - ...ROOT_MODULES ], declarations: [ ...PIPES, @@ -444,8 +466,7 @@ const DIRECTIVES = [ ...DIRECTIVES, ...ENTRY_COMPONENTS, ...SHARED_ITEM_PAGE_COMPONENTS, - PublicationSearchResultListElementComponent, - ExistingMetadataListElementComponent + ], providers: [ ...PROVIDERS diff --git a/src/app/shared/testing/auth-request-service-stub.ts b/src/app/shared/testing/auth-request-service-stub.ts index 82ce682a9b..e89af1d666 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; @@ -23,15 +21,24 @@ export class AuthRequestServiceStub { } else { authStatusStub.authenticated = false; } - } else { + } else if (isNotEmpty(options)) { const token = (options.headers as any).lazyUpdate[1].value; 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; } + } else { + authStatusStub.authenticated = false; } return observableOf(authStatusStub); } @@ -43,11 +50,18 @@ export class AuthRequestServiceStub { authStatusStub.authenticated = false; break; case 'status': - const token = (options.headers as any).lazyUpdate[1].value; + const token = ((options.headers as any).lazyUpdate[1]) ? (options.headers as any).lazyUpdate[1].value : null; 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..26ce79cb5f 100644 --- a/src/app/shared/testing/auth-service-stub.ts +++ b/src/app/shared/testing/auth-service-stub.ts @@ -3,8 +3,13 @@ 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'; +import { AuthMethod } from '../../core/auth/models/auth.method'; + +export const authMethodsMock = [ + new AuthMethod('password'), + new AuthMethod('shibboleth', 'dspace.test/shibboleth') +]; export class AuthServiceStub { @@ -30,14 +35,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}`; } @@ -103,4 +112,12 @@ export class AuthServiceStub { isAuthenticated() { return observableOf(true); } + + checkAuthenticationCookie() { + return; + } + + retrieveAuthMethodsFromAuthStatus(status: AuthStatus) { + return observableOf(authMethodsMock); + } } diff --git a/src/app/shared/testing/eperson-mock.ts b/src/app/shared/testing/eperson-mock.ts index 44585f278f..81a5a48a2c 100644 --- a/src/app/shared/testing/eperson-mock.ts +++ b/src/app/shared/testing/eperson-mock.ts @@ -1,6 +1,7 @@ import { EPerson } from '../../core/eperson/models/eperson.model'; +import { GroupMock } from './group-mock'; -export const EPersonMock: EPerson = Object.assign(new EPerson(),{ +export const EPersonMock: EPerson = Object.assign(new EPerson(), { handle: null, groups: [], netid: 'test@test.com', @@ -12,7 +13,8 @@ export const EPersonMock: EPerson = Object.assign(new EPerson(),{ _links: { self: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons/testid', - } + }, + groups: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons/testid/groups' } }, id: 'testid', uuid: 'testid', @@ -44,3 +46,49 @@ export const EPersonMock: EPerson = Object.assign(new EPerson(),{ ] } }); + +export const EPersonMock2: EPerson = Object.assign(new EPerson(), { + handle: null, + groups: [GroupMock], + netid: 'test2@test.com', + lastActive: '2019-05-14T12:25:42.411+0000', + canLogIn: false, + email: 'test2@test.com', + requireCertificate: false, + selfRegistered: true, + _links: { + self: { + href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons/testid2', + }, + groups: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons/testid2/groups' } + }, + id: 'testid2', + uuid: 'testid2', + type: 'eperson', + metadata: { + 'dc.title': [ + { + language: null, + value: 'User Test 2' + } + ], + 'eperson.firstname': [ + { + language: null, + value: 'User2' + } + ], + 'eperson.lastname': [ + { + language: null, + value: 'MeepMeep' + }, + ], + 'eperson.language': [ + { + language: null, + value: 'fr' + }, + ] + } +}); diff --git a/src/app/shared/testing/group-mock.ts b/src/app/shared/testing/group-mock.ts new file mode 100644 index 0000000000..0c9abb4b7d --- /dev/null +++ b/src/app/shared/testing/group-mock.ts @@ -0,0 +1,16 @@ +import { Group } from '../../core/eperson/models/group.model'; + +export const GroupMock: Group = Object.assign(new Group(), { + handle: null, + groups: [], + selfRegistered: false, + _links: { + self: { + href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid', + }, + groups: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid/groups' } + }, + id: 'testgroupid', + uuid: 'testgroupid', + type: 'group', +}); diff --git a/src/app/shared/translate/missing-translation.helper.ts b/src/app/shared/translate/missing-translation.helper.ts new file mode 100644 index 0000000000..71a1dc3620 --- /dev/null +++ b/src/app/shared/translate/missing-translation.helper.ts @@ -0,0 +1,18 @@ +import { MissingTranslationHandler, MissingTranslationHandlerParams } from '@ngx-translate/core'; + +/** + * Class to handle missing translations for the ngx-translate library + */ +export class MissingTranslationHelper implements MissingTranslationHandler { + /** + * Called when there is not translation for a specific key + * Will return the 'default' parameter of the translate pipe, if there is one available + * @param params + */ + handle(params: MissingTranslationHandlerParams) { + if (params.interpolateParams) { + return (params.interpolateParams as any).default || params.key; + } + return params.key; + } +} diff --git a/src/app/shared/truncatable/truncatable.component.spec.ts b/src/app/shared/truncatable/truncatable.component.spec.ts index d083c27d07..176beb0f15 100644 --- a/src/app/shared/truncatable/truncatable.component.spec.ts +++ b/src/app/shared/truncatable/truncatable.component.spec.ts @@ -1,5 +1,6 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { of as observableOf } from 'rxjs'; +import { mockTruncatableService } from '../mocks/mock-trucatable.service'; import { TruncatableComponent } from './truncatable.component'; import { TruncatableService } from './truncatable.service'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; @@ -10,29 +11,12 @@ describe('TruncatableComponent', () => { let fixture: ComponentFixture; const identifier = '1234567890'; let truncatableService; - const truncatableServiceStub: any = { - /* tslint:disable:no-empty */ - isCollapsed: (id: string) => { - if (id === '1') { - return observableOf(true) - } else { - return observableOf(false); - } - }, - expand: (id: string) => { - }, - collapse: (id: string) => { - }, - toggle: (id: string) => { - } - /* tslint:enable:no-empty */ - }; beforeEach(async(() => { TestBed.configureTestingModule({ imports: [NoopAnimationsModule], declarations: [TruncatableComponent], providers: [ - { provide: TruncatableService, useValue: truncatableServiceStub }, + { provide: TruncatableService, useValue: mockTruncatableService }, ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(TruncatableComponent, { diff --git a/src/app/shared/utils/follow-link-config.model.ts b/src/app/shared/utils/follow-link-config.model.ts index 21df288690..87942d8467 100644 --- a/src/app/shared/utils/follow-link-config.model.ts +++ b/src/app/shared/utils/follow-link-config.model.ts @@ -23,6 +23,11 @@ export class FollowLinkConfig { * use on the retrieved object. */ linksToFollow?: Array>; + + /** + * Forward to rest which links we're following, so these can already be embedded + */ + shouldEmbed? = true; } /** @@ -36,15 +41,19 @@ export class FollowLinkConfig { * in a certain way * @param linksToFollow: a list of {@link FollowLinkConfig}s to * use on the retrieved object. + * @param shouldEmbed: boolean to check whether to forward info on followLinks to rest, + * so these can be embedded, default true */ export const followLink = ( linkName: keyof R['_links'], findListOptions?: FindListOptions, + shouldEmbed = true, ...linksToFollow: Array> ): FollowLinkConfig => { return { name: linkName, findListOptions, + shouldEmbed: shouldEmbed, linksToFollow } }; 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/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(); diff --git a/src/config/auth-config.interfaces.ts b/src/config/auth-config.interfaces.ts new file mode 100644 index 0000000000..cc3d97c6b8 --- /dev/null +++ b/src/config/auth-config.interfaces.ts @@ -0,0 +1,10 @@ +import { Config } from './config.interface'; + +export interface AuthTarget { + host: string; + page: string; +} + +export interface AuthConfig extends Config { + target: AuthTarget; +} diff --git a/src/config/global-config.interface.ts b/src/config/global-config.interface.ts index dec23ff676..f361e6def6 100644 --- a/src/config/global-config.interface.ts +++ b/src/config/global-config.interface.ts @@ -10,12 +10,14 @@ import { BrowseByConfig } from './browse-by-config.interface'; import { ItemPageConfig } from './item-page-config.interface'; import { CollectionPageConfig } from './collection-page-config.interface'; import { Theme } from './theme.inferface'; +import {AuthConfig} from './auth-config.interfaces'; export interface GlobalConfig extends Config { ui: ServerConfig; rest: ServerConfig; production: boolean; cache: CacheConfig; + auth: AuthConfig; form: FormConfig; notifications: INotificationBoardOptions; submission: SubmissionConfig;