-
+
+ +
+ +
diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 1f3da086c2..4e029af8ca 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,4 +1,4 @@ -import { filter, map, take } from 'rxjs/operators'; +import { delay, filter, map, take } from 'rxjs/operators'; import { AfterViewInit, ChangeDetectionStrategy, Component, HostListener, Inject, OnInit, ViewEncapsulation } from '@angular/core'; import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router'; @@ -19,7 +19,7 @@ import variables from '../styles/_exposed_variables.scss'; import { CSSVariableService } from './shared/sass-helper/sass-helper.service'; import { MenuService } from './shared/menu/menu.service'; import { MenuID } from './shared/menu/initial-menus-state'; -import { combineLatest as combineLatestObservable, Observable, of } from 'rxjs'; +import { BehaviorSubject, combineLatest as combineLatestObservable, Observable, of } from 'rxjs'; import { slideSidebarPadding } from './shared/animations/slide'; import { HostWindowService } from './shared/host-window.service'; import { Theme } from '../config/theme.inferface'; @@ -38,7 +38,7 @@ export const LANG_COOKIE = 'language_cookie'; animations: [slideSidebarPadding] }) export class AppComponent implements OnInit, AfterViewInit { - isLoading = true; + isLoading$: BehaviorSubject = new BehaviorSubject(true); sidebarVisible: Observable; slideSidebarOver: Observable; collapsedSidebarWidth: Observable; @@ -125,15 +125,18 @@ export class AppComponent implements OnInit, AfterViewInit { } ngAfterViewInit() { - this.router.events - .subscribe((event) => { + this.router.events.pipe( + // This fixes an ExpressionChangedAfterItHasBeenCheckedError from being thrown while loading the component + // More information on this bug-fix: https://blog.angular-university.io/angular-debugging/ + delay(0) + ).subscribe((event) => { if (event instanceof NavigationStart) { - this.isLoading = true; + this.isLoading$.next(true); } else if ( event instanceof NavigationEnd || event instanceof NavigationCancel ) { - this.isLoading = false; + this.isLoading$.next(false); } }); } diff --git a/src/app/app.metareducers.ts b/src/app/app.metareducers.ts index 4d94c899d7..131d240b79 100644 --- a/src/app/app.metareducers.ts +++ b/src/app/app.metareducers.ts @@ -1,4 +1,3 @@ -import { isNotEmpty } from './shared/empty.util'; import { StoreActionTypes } from './store.actions'; // fallback ngrx debugger diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 4b803608f3..aaad66adf6 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -3,17 +3,14 @@ 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 } 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'; -import { storeFreeze } from 'ngrx-store-freeze'; - import { ENV_CONFIG, GLOBAL_CONFIG, GlobalConfig } from '../config'; import { AdminSidebarSectionComponent } from './+admin/admin-sidebar/admin-sidebar-section/admin-sidebar-section.component'; import { AdminSidebarComponent } from './+admin/admin-sidebar/admin-sidebar.component'; @@ -41,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; @@ -51,8 +49,7 @@ export function getBase() { } export function getMetaReducers(config: GlobalConfig): Array> { - const metaReducers: Array> = config.production ? appMetaReducers : [...appMetaReducers, storeFreeze]; - return config.debug ? [...metaReducers, ...debugMetaReducers] : metaReducers; + return config.debug ? [...appMetaReducers, ...debugMetaReducers] : appMetaReducers; } const IMPORTS = [ @@ -63,11 +60,11 @@ const IMPORTS = [ AppRoutingModule, CoreModule.forRoot(), ScrollToModule.forRoot(), - NgbModule.forRoot(), + NgbModule, TranslateModule.forRoot(), EffectsModule.forRoot(appEffects), StoreModule.forRoot(appReducers), - StoreRouterConnectingModule, + StoreRouterConnectingModule.forRoot(), ]; const ENTITY_IMPORTS = [ @@ -92,7 +89,7 @@ const PROVIDERS = [ useFactory: (getBase) }, { - provide: META_REDUCERS, + provide: USER_PROVIDED_META_REDUCERS, useFactory: getMetaReducers, deps: [GLOBAL_CONFIG] }, @@ -100,7 +97,8 @@ const PROVIDERS = [ provide: RouterStateSerializer, useClass: DSpaceRouterStateSerializer }, - ClientCookieService + ClientCookieService, + ...DYNAMIC_MATCHER_PROVIDERS, ]; const DECLARATIONS = [ @@ -131,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..e25ddcd44d 100644 --- a/src/app/app.reducer.ts +++ b/src/app/app.reducer.ts @@ -1,30 +1,38 @@ -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 { + groupRegistryReducer, + GroupRegistryState +} from './+admin/admin-access-control/group-registry/group-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 +50,8 @@ export interface AppState { selectableLists: SelectableListsState; relationshipLists: NameVariantListsState; communityList: CommunityListState; + epeopleRegistry: EPeopleRegistryState; + groupRegistry: GroupRegistryState; } export const appReducers: ActionReducerMap = { @@ -60,6 +70,8 @@ export const appReducers: ActionReducerMap = { selectableLists: selectableListReducer, relationshipLists: nameVariantReducer, communityList: CommunityListReducer, + epeopleRegistry: ePeopleRegistryReducer, + groupRegistry: groupRegistryReducer, }; 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/community-list-page/community-list-service.spec.ts b/src/app/community-list-page/community-list-service.spec.ts index a150277d20..c3cfef35a0 100644 --- a/src/app/community-list-page/community-list-service.spec.ts +++ b/src/app/community-list-page/community-list-service.spec.ts @@ -190,8 +190,6 @@ describe('CommunityListService', () => { service = new CommunityListService(communityDataServiceStub, collectionDataServiceStub, store); })); - afterAll(() => service = new CommunityListService(communityDataServiceStub, collectionDataServiceStub, store)); - it('should create', inject([CommunityListService], (serviceIn: CommunityListService) => { expect(serviceIn).toBeTruthy(); })); diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index 8773b1a9fb..e5c9210769 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -10,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 { @@ -18,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 { @@ -38,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)), @@ -67,4 +69,5 @@ export class AuthRequestService { mergeMap((request: GetRequest) => this.fetchRequest(request)), distinctUntilChanged()); } + } diff --git a/src/app/core/auth/auth-response-parsing.service.spec.ts b/src/app/core/auth/auth-response-parsing.service.spec.ts index 112d60b8d2..3b18d925bf 100644 --- a/src/app/core/auth/auth-response-parsing.service.spec.ts +++ b/src/app/core/auth/auth-response-parsing.service.spec.ts @@ -3,15 +3,16 @@ import { async, TestBed } from '@angular/core/testing'; import { Store, StoreModule } from '@ngrx/store'; import { GlobalConfig } from '../../../config/global-config.interface'; -import { AuthStatusResponse } from '../cache/response.models'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { AuthStatus } from './models/auth-status.model'; -import { AuthResponseParsingService } from './auth-response-parsing.service'; -import { AuthGetRequest, AuthPostRequest } from '../data/request.models'; import { MockStore } from '../../shared/testing/mock-store'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { AuthStatusResponse } from '../cache/response.models'; +import { AuthGetRequest, AuthPostRequest } from '../data/request.models'; +import { AuthResponseParsingService } from './auth-response-parsing.service'; +import { AuthStatus } from './models/auth-status.model'; describe('AuthResponseParsingService', () => { let service: AuthResponseParsingService; + let linkServiceStub: any; const EnvConfig: GlobalConfig = { cache: { msToLive: 1000 } } as any; let store: any; @@ -30,7 +31,10 @@ describe('AuthResponseParsingService', () => { beforeEach(() => { store = TestBed.get(Store); - objectCacheService = new ObjectCacheService(store as any); + linkServiceStub = jasmine.createSpyObj({ + removeResolvedLinks: {} + }); + objectCacheService = new ObjectCacheService(store as any, linkServiceStub); service = new AuthResponseParsingService(EnvConfig, objectCacheService); }); @@ -141,6 +145,7 @@ describe('AuthResponseParsingService', () => { it('should return a AuthStatusResponse if data contains a valid endpoint response', () => { const response = service.parse(validRequest2, validResponse2); expect(response.constructor).toBe(AuthStatusResponse); + expect(linkServiceStub.removeResolvedLinks).toHaveBeenCalled(); }); it('should return a AuthStatusResponse if data contains an empty 404 endpoint response', () => { diff --git a/src/app/core/auth/auth-response-parsing.service.ts b/src/app/core/auth/auth-response-parsing.service.ts index 8137734c49..9ef523ca14 100644 --- a/src/app/core/auth/auth-response-parsing.service.ts +++ b/src/app/core/auth/auth-response-parsing.service.ts @@ -10,8 +10,6 @@ import { ObjectCacheService } from '../cache/object-cache.service'; import { ResponseParsingService } from '../data/parsing.service'; import { RestRequest } from '../data/request.models'; import { AuthStatus } from './models/auth-status.model'; -import { NormalizedAuthStatus } from './models/normalized-auth-status.model'; -import { NormalizedObject } from '../cache/models/normalized-object.model'; @Injectable() export class AuthResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { @@ -25,10 +23,10 @@ export class AuthResponseParsingService extends BaseResponseParsingService imple parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { if (isNotEmpty(data.payload) && isNotEmpty(data.payload._links) && (data.statusCode === 200)) { - const response = this.process>(data.payload, request); + const response = this.process(data.payload, request); return new AuthStatusResponse(response, data.statusCode, data.statusText); } else { - return new AuthStatusResponse(data.payload as NormalizedAuthStatus, data.statusCode, data.statusText); + return new AuthStatusResponse(data.payload as AuthStatus, data.statusCode, data.statusText); } } } 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 86794f257b..03759987bf 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -11,7 +11,6 @@ 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'; @@ -22,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; @@ -38,7 +48,7 @@ describe('AuthService test', () => { let storage: CookieService; let token: AuthTokenInfo; let authenticatedState; - let rdbService; + let linkService; function init() { mockStore = jasmine.createSpyObj('store', { @@ -58,8 +68,10 @@ describe('AuthService test', () => { }; authRequest = new AuthRequestServiceStub(); routeStub = new ActivatedRouteStub(); - rdbService = getMockRemoteDataBuildService(); - spyOn(rdbService, 'build').and.returnValue({authenticated: true, eperson: observableOf({payload: {}})}); + linkService = { + resolveLinks: {} + }; + spyOn(linkService, 'resolveLinks').and.returnValue({ authenticated: true, eperson: observableOf({ payload: {} }) }); } @@ -80,7 +92,7 @@ describe('AuthService test', () => { { provide: RouteService, useValue: routeServiceStub }, { provide: ActivatedRoute, useValue: routeStub }, { provide: Store, useValue: mockStore }, - { provide: RemoteDataBuildService, useValue: rdbService }, + { provide: EPersonDataService, useValue: mockEpersonDataService }, CookieService, AuthService ], @@ -98,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(); }); }); @@ -128,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('', () => { @@ -143,7 +181,7 @@ describe('AuthService test', () => { { provide: REQUEST, useValue: {} }, { provide: Router, useValue: routerStub }, { provide: RouteService, useValue: routeServiceStub }, - { provide: RemoteDataBuildService, useValue: rdbService }, + { provide: RemoteDataBuildService, useValue: linkService }, CookieService, AuthService ] @@ -156,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, rdbService); + authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store); })); it('should return true when user is logged in', () => { @@ -195,7 +233,7 @@ describe('AuthService test', () => { { provide: REQUEST, useValue: {} }, { provide: Router, useValue: routerStub }, { provide: RouteService, useValue: routeServiceStub }, - { provide: RemoteDataBuildService, useValue: rdbService }, + { provide: RemoteDataBuildService, useValue: linkService }, ClientCookieService, CookieService, AuthService @@ -218,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, rdbService); + authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store); storage = (authService as any).storage; routeServiceMock = TestBed.get(RouteService); routerStub = TestBed.get(Router); @@ -247,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); @@ -255,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); @@ -263,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); @@ -271,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 a536313521..0f5c06bbc9 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -4,7 +4,7 @@ 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'; @@ -18,15 +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 { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import {RouteService} from '../services/route.service'; +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'; /** @@ -43,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 rdbService: RemoteDataBuildService + protected store: Store ) { this.store.pipe( select(isAuthenticated), @@ -113,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} @@ -122,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(); @@ -133,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.rdbService.build(status)), - 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')); } @@ -144,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()); } /** @@ -177,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) { @@ -196,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 e0d568397a..197c025407 100644 --- a/src/app/core/auth/models/auth-status.model.ts +++ b/src/app/core/auth/models/auth-status.model.ts @@ -1,54 +1,92 @@ -import { AuthError } from './auth-error.model'; -import { AuthTokenInfo } from './auth-token-info.model'; -import { EPerson } from '../../eperson/models/eperson.model'; -import { RemoteData } from '../../data/remote-data'; +import { autoserialize, deserialize, deserializeAs } from 'cerialize'; import { Observable } from 'rxjs'; +import { link, typedObject } from '../../cache/builders/build-decorators'; +import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer'; import { CacheableObject } from '../../cache/object-cache.reducer'; +import { RemoteData } from '../../data/remote-data'; +import { EPerson } from '../../eperson/models/eperson.model'; +import { EPERSON } from '../../eperson/models/eperson.resource-type'; +import { HALLink } from '../../shared/hal-link.model'; import { ResourceType } from '../../shared/resource-type'; +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 */ +@typedObject export class AuthStatus implements CacheableObject { - static type = new ResourceType('status'); + static type = AUTH_STATUS; /** * The unique identifier of this auth status */ + @autoserialize id: string; /** - * The unique uuid of this auth status + * The type for this AuthStatus */ + @excludeFromEquals + @autoserialize + type: ResourceType; + + /** + * The UUID of this auth status + * This UUID is generated client-side and isn't used by the backend. + * It is based on the ID, so it will be the same for each refresh. + */ + @deserializeAs(new IDToUUIDSerializer('auth-status'), 'id') uuid: string; /** * True if REST API is up and running, should never return false */ + @autoserialize okay: boolean; /** * If the auth status represents an authenticated state */ + @autoserialize authenticated: boolean; /** - * Authentication error if there was one for this status + * The {@link HALLink}s for this AuthStatus */ - error?: AuthError; + @deserialize + _links: { + self: HALLink; + eperson: HALLink; + }; /** - * The eperson of this auth status + * The EPerson of this auth status + * Will be undefined unless the eperson {@link HALLink} has been resolved. */ - eperson: Observable>; + @link(EPERSON) + eperson?: Observable>; /** * True if the token is valid, false if there was no token or the token wasn't valid */ + @autoserialize token?: AuthTokenInfo; /** - * The self link of this auth status' REST object + * Authentication error if there was one for this status */ - self: string; + // 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-status.resource-type.ts b/src/app/core/auth/models/auth-status.resource-type.ts new file mode 100644 index 0000000000..2b7c7252fc --- /dev/null +++ b/src/app/core/auth/models/auth-status.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for AuthStatus + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const AUTH_STATUS = new ResourceType('status'); 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 index 3892bee408..e69de29bb2 100644 --- a/src/app/core/auth/models/normalized-auth-status.model.ts +++ b/src/app/core/auth/models/normalized-auth-status.model.ts @@ -1,41 +0,0 @@ -import { AuthStatus } from './auth-status.model'; -import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; -import { mapsTo, relationship } from '../../cache/builders/build-decorators'; -import { NormalizedObject } from '../../cache/models/normalized-object.model'; -import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer'; -import { EPerson } from '../../eperson/models/eperson.model'; - -@mapsTo(AuthStatus) -@inheritSerialization(NormalizedObject) -export class NormalizedAuthStatus extends NormalizedObject { - /** - * The unique identifier of this auth status - */ - @autoserialize - id: string; - - /** - * The unique generated uuid of this auth status - */ - @autoserializeAs(new IDToUUIDSerializer('auth-status'), 'id') - uuid: string; - - /** - * True if REST API is up and running, should never return false - */ - @autoserialize - okay: boolean; - - /** - * True if the token is valid, false if there was no token or the token wasn't valid - */ - @autoserialize - authenticated: boolean; - - /** - * The self link to the eperson of this auth status - */ - @relationship(EPerson, false) - @autoserialize - eperson: string; -} 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 cf4d4a658e..30767be85a 100644 --- a/src/app/core/auth/server-auth.service.ts +++ b/src/app/core/auth/server-auth.service.ts @@ -1,15 +1,14 @@ -import { filter, map, switchMap, take } from 'rxjs/operators'; import { Injectable } from '@angular/core'; +import { HttpHeaders } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { HttpHeaders } from '@angular/common/http'; +import { filter, map, take } from 'rxjs/operators'; + +import { isNotEmpty } from '../../shared/empty.util'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { AuthService } from './auth.service'; import { AuthStatus } from './models/auth-status.model'; -import { isEmpty, isNotEmpty } from '../../shared/empty.util'; -import { AuthService, LOGIN_ROUTE } from './auth.service'; import { AuthTokenInfo } from './models/auth-token-info.model'; -import { CheckAuthenticationTokenAction } from './auth.actions'; -import { EPerson } from '../eperson/models/eperson.model'; /** * The auth service. @@ -21,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(); @@ -34,10 +33,9 @@ export class ServerAuthService extends AuthService { options.headers = headers; return this.authRequestService.getRequest('status', options).pipe( - map((status) => this.rdbService.build(status)), - 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')); } @@ -45,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/browse/browse.service.spec.ts b/src/app/core/browse/browse.service.spec.ts index 55ff7a090e..6dafa4cf0a 100644 --- a/src/app/core/browse/browse.service.spec.ts +++ b/src/app/core/browse/browse.service.spec.ts @@ -1,16 +1,16 @@ import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { of as observableOf } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { BrowseEndpointRequest, BrowseEntriesRequest, BrowseItemsRequest } from '../data/request.models'; +import { RequestEntry } from '../data/request.reducer'; import { RequestService } from '../data/request.service'; import { BrowseDefinition } from '../shared/browse-definition.model'; -import { BrowseService } from './browse.service'; import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; -import { RequestEntry } from '../data/request.reducer'; -import { of as observableOf } from 'rxjs'; +import { BrowseService } from './browse.service'; describe('BrowseService', () => { let scheduler: TestScheduler; @@ -44,8 +44,8 @@ describe('BrowseService', () => { 'dc.date.issued' ], _links: { - self: 'https://rest.api/discover/browses/dateissued', - items: 'https://rest.api/discover/browses/dateissued/items' + self: { href: 'https://rest.api/discover/browses/dateissued' }, + items: { href: 'https://rest.api/discover/browses/dateissued/items' } } }), Object.assign(new BrowseDefinition(), { @@ -72,9 +72,9 @@ describe('BrowseService', () => { 'dc.creator' ], _links: { - self: 'https://rest.api/discover/browses/author', - entries: 'https://rest.api/discover/browses/author/entries', - items: 'https://rest.api/discover/browses/author/items' + self: { href: 'https://rest.api/discover/browses/author' }, + entries: { href: 'https://rest.api/discover/browses/author/entries' }, + items: { href: 'https://rest.api/discover/browses/author/items' } } }) ]; @@ -125,9 +125,11 @@ describe('BrowseService', () => { }); it('should return a RemoteData object containing the correct BrowseDefinition[]', () => { - const expected = cold('--a-', { a: { - payload: browseDefinitions - }}); + const expected = cold('--a-', { + a: { + payload: browseDefinitions + } + }); expect(service.getBrowseDefinitions()).toBeObservable(expected); }); @@ -142,15 +144,17 @@ describe('BrowseService', () => { rdbService = getMockRemoteDataBuildService(); service = initTestService(); spyOn(service, 'getBrowseDefinitions').and - .returnValue(hot('--a-', { a: { + .returnValue(hot('--a-', { + a: { payload: browseDefinitions - }})); + } + })); spyOn(rdbService, 'toRemoteDataObservable').and.callThrough(); }); describe('when getBrowseEntriesFor is called with a valid browse definition id', () => { it('should configure a new BrowseEntriesRequest', () => { - const expected = new BrowseEntriesRequest(requestService.generateRequestId(), browseDefinitions[1]._links.entries); + const expected = new BrowseEntriesRequest(requestService.generateRequestId(), browseDefinitions[1]._links.entries.href); scheduler.schedule(() => service.getBrowseEntriesFor(new BrowseEntrySearchOptions(browseDefinitions[1].id)).subscribe()); scheduler.flush(); @@ -169,7 +173,7 @@ describe('BrowseService', () => { describe('when getBrowseItemsFor is called with a valid browse definition id', () => { it('should configure a new BrowseItemsRequest', () => { - const expected = new BrowseItemsRequest(requestService.generateRequestId(), browseDefinitions[1]._links.items + '?filterValue=' + mockAuthorName); + const expected = new BrowseItemsRequest(requestService.generateRequestId(), browseDefinitions[1]._links.items.href + '?filterValue=' + mockAuthorName); scheduler.schedule(() => service.getBrowseItemsFor(mockAuthorName, new BrowseEntrySearchOptions(browseDefinitions[1].id)).subscribe()); scheduler.flush(); @@ -215,9 +219,11 @@ describe('BrowseService', () => { rdbService = getMockRemoteDataBuildService(); service = initTestService(); spyOn(service, 'getBrowseDefinitions').and - .returnValue(hot('--a-', { a: { + .returnValue(hot('--a-', { + a: { payload: browseDefinitions - }})); + } + })); }); it('should return the URL for the given metadataKey and linkPath', () => { @@ -288,14 +294,16 @@ describe('BrowseService', () => { rdbService = getMockRemoteDataBuildService(); service = initTestService(); spyOn(service, 'getBrowseDefinitions').and - .returnValue(hot('--a-', { a: { + .returnValue(hot('--a-', { + a: { payload: browseDefinitions - }})); + } + })); spyOn(rdbService, 'toRemoteDataObservable').and.callThrough(); }); describe('when getFirstItemFor is called with a valid browse definition id', () => { - const expectedURL = browseDefinitions[1]._links.items + '?page=0&size=1'; + const expectedURL = browseDefinitions[1]._links.items.href + '?page=0&size=1'; it('should configure a new BrowseItemsRequest', () => { const expected = new BrowseItemsRequest(requestService.generateRequestId(), expectedURL); diff --git a/src/app/core/browse/browse.service.ts b/src/app/core/browse/browse.service.ts index eb494d7bdb..78e63e8540 100644 --- a/src/app/core/browse/browse.service.ts +++ b/src/app/core/browse/browse.service.ts @@ -10,18 +10,16 @@ import { isNotEmptyOperator } from '../../shared/empty.util'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { GenericSuccessResponse } from '../cache/response.models'; import { PaginatedList } from '../data/paginated-list'; import { RemoteData } from '../data/remote-data'; -import { - BrowseEndpointRequest, - BrowseEntriesRequest, - BrowseItemsRequest, - RestRequest -} from '../data/request.models'; +import { BrowseEndpointRequest, BrowseEntriesRequest, BrowseItemsRequest, RestRequest } from '../data/request.models'; import { RequestService } from '../data/request.service'; import { BrowseDefinition } from '../shared/browse-definition.model'; import { BrowseEntry } from '../shared/browse-entry.model'; +import { DSpaceObject } from '../shared/dspace-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { Item } from '../shared/item.model'; import { configureRequest, filterSuccessfulResponses, @@ -31,10 +29,7 @@ import { getRequestFromRequestHref } from '../shared/operators'; import { URLCombiner } from '../url-combiner/url-combiner'; -import { Item } from '../shared/item.model'; -import { DSpaceObject } from '../shared/dspace-object.model'; import { BrowseEntrySearchOptions } from './browse-entry-search-options.model'; -import { GenericSuccessResponse } from '../cache/response.models'; /** * The service handling all browse requests @@ -81,10 +76,11 @@ export class BrowseService { map((response: GenericSuccessResponse) => response.payload), ensureArrayHasValue(), map((definitions: BrowseDefinition[]) => definitions - .map((definition: BrowseDefinition) => Object.assign(new BrowseDefinition(), definition))), - distinctUntilChanged() + .map((definition: BrowseDefinition) => { + return Object.assign(new BrowseDefinition(), definition) + })), + distinctUntilChanged(), ); - return this.rdb.toRemoteDataObservable(requestEntry$, payload$); } @@ -96,7 +92,10 @@ export class BrowseService { return this.getBrowseDefinitions().pipe( getBrowseDefinitionLinks(options.metadataDefinition), hasValueOperator(), - map((_links: any) => _links.entries), + map((_links: any) => { + const entriesLink = _links.entries.href || _links.entries; + return entriesLink; + }), hasValueOperator(), map((href: string) => { // TODO nearly identical to PaginatedSearchOptions => refactor @@ -133,7 +132,10 @@ export class BrowseService { return this.getBrowseDefinitions().pipe( getBrowseDefinitionLinks(options.metadataDefinition), hasValueOperator(), - map((_links: any) => _links.items), + map((_links: any) => { + const itemsLink = _links.items.href || _links.items; + return itemsLink; + }), hasValueOperator(), map((href: string) => { const args = []; @@ -171,7 +173,10 @@ export class BrowseService { return this.getBrowseDefinitions().pipe( getBrowseDefinitionLinks(definition), hasValueOperator(), - map((_links: any) => _links.items), + map((_links: any) => { + const itemsLink = _links.items.href || _links.items; + return itemsLink; + }), hasValueOperator(), map((href: string) => { const args = []; @@ -249,7 +254,7 @@ export class BrowseService { if (isEmpty(def) || isEmpty(def._links) || isEmpty(def._links[linkPath])) { throw new Error(`A browse endpoint for ${linkPath} on ${metadataKey} isn't configured`); } else { - return def._links[linkPath]; + return def._links[linkPath] || def._links[linkPath].href; } }), startWith(undefined), diff --git a/src/app/core/cache/builders/build-decorators.spec.ts b/src/app/core/cache/builders/build-decorators.spec.ts new file mode 100644 index 0000000000..e47cf1a80a --- /dev/null +++ b/src/app/core/cache/builders/build-decorators.spec.ts @@ -0,0 +1,83 @@ +import { HALLink } from '../../shared/hal-link.model'; +import { HALResource } from '../../shared/hal-resource.model'; +import { ResourceType } from '../../shared/resource-type'; +import { + dataService, + getDataServiceFor, + getLinkDefinition, + link, +} from './build-decorators'; + +/* tslint:disable:max-classes-per-file */ +class TestService {} +class AnotherTestService {} +class TestHALResource implements HALResource { + _links: { + self: HALLink; + foo: HALLink; + }; + + bar?: any +} +let testType; + +describe('build decorators', () => { + beforeEach(() => { + testType = new ResourceType('testType-' + new Date().getTime()); + }); + describe('@dataService/getDataServiceFor', () => { + + it('should register a resourcetype for a dataservice', () => { + dataService(testType)(TestService); + expect(getDataServiceFor(testType)).toBe(TestService); + }); + + describe(`when the resource type isn't specified`, () => { + it(`should throw an error`, () => { + expect(() => { + dataService(undefined)(TestService); + }).toThrow(); + }); + }); + + describe(`when there already is a registered dataservice for a resourcetype`, () => { + it(`should throw an error`, () => { + dataService(testType)(TestService); + expect(() => { + dataService(testType)(AnotherTestService); + }).toThrow(); + }); + }); + + }); + + describe(`@link/getLinkDefinitions`, () => { + it(`should register a link`, () => { + const target = new TestHALResource(); + link(testType, true, 'foo')(target, 'bar'); + const result = getLinkDefinition(TestHALResource, 'foo'); + expect(result.resourceType).toBe(testType); + expect(result.isList).toBe(true); + expect(result.linkName).toBe('foo'); + expect(result.propertyName).toBe('bar'); + }); + + describe(`when the linkname isn't specified`, () => { + it(`should use the propertyname`, () => { + const target = new TestHALResource(); + link(testType)(target, 'foo'); + const result = getLinkDefinition(TestHALResource, 'foo'); + expect(result.linkName).toBe('foo'); + expect(result.propertyName).toBe('foo'); + }); + }); + + describe(`when there's no @link`, () => { + it(`should return undefined`, () => { + const result = getLinkDefinition(TestHALResource, 'self'); + expect(result).toBeUndefined(); + }); + }); + }); +}); +/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/cache/builders/build-decorators.ts b/src/app/core/cache/builders/build-decorators.ts index 0bfb5f0321..4ba04bfa55 100644 --- a/src/app/core/cache/builders/build-decorators.ts +++ b/src/app/core/cache/builders/build-decorators.ts @@ -1,80 +1,161 @@ import 'reflect-metadata'; +import { hasNoValue, hasValue } from '../../../shared/empty.util'; +import { DataService } from '../../data/data.service'; import { GenericConstructor } from '../../shared/generic-constructor'; -import { CacheableObject, TypedObject } from '../object-cache.reducer'; +import { HALResource } from '../../shared/hal-resource.model'; import { ResourceType } from '../../shared/resource-type'; +import { CacheableObject, TypedObject } from '../object-cache.reducer'; -const mapsToMetadataKey = Symbol('mapsTo'); -const relationshipKey = Symbol('relationship'); +const resolvedLinkKey = Symbol('resolvedLink'); -const relationshipMap = new Map(); +const resolvedLinkMap = new Map(); const typeMap = new Map(); +const dataServiceMap = new Map(); +const linkMap = new Map(); /** - * Decorator function to map a normalized class to it's not-normalized counter part class - * It will also maps a type to the matching class - * @param value The not-normalized class to map to + * Decorator function to map a ResourceType to its class + * @param target The contructor of the typed class to map */ -export function mapsTo(value: GenericConstructor) { - return function decorator(objectConstructor: GenericConstructor) { - Reflect.defineMetadata(mapsToMetadataKey, value, objectConstructor); - mapsToType((value as any).type, objectConstructor); - } -} - -/** - * Maps a type to the matching class - * @param value The resourse type - * @param objectConstructor The class to map to - */ -function mapsToType(value: ResourceType, objectConstructor: GenericConstructor) { - if (!objectConstructor || !value) { - return; - } - typeMap.set(value.value, objectConstructor); -} - -/** - * Returns the mapped class for the given normalized class - * @param target The normalized class - */ -export function getMapsTo(target: any) { - return Reflect.getOwnMetadata(mapsToMetadataKey, target); +export function typedObject(target: typeof TypedObject) { + typeMap.set(target.type.value, target); } /** * Returns the mapped class for the given type * @param type The resource type */ -export function getMapsToType(type: string | ResourceType) { +export function getClassForType(type: string | ResourceType) { if (typeof(type) === 'object') { type = (type as ResourceType).value; } return typeMap.get(type); } -export function relationship(value: GenericConstructor, isList: boolean = false): any { - return function r(target: any, propertyKey: string, descriptor: PropertyDescriptor) { - if (!target || !propertyKey) { - return; +/** + * A class decorator to indicate that this class is a dataservice + * for a given resource type. + * + * "dataservice" in this context means that it has findByHref and + * findAllByHref methods. + * + * @param resourceType the resource type the class is a dataservice for + */ +export function dataService(resourceType: ResourceType): any { + return (target: any) => { + if (hasNoValue(resourceType)) { + throw new Error(`Invalid @dataService annotation on ${target}, resourceType needs to be defined`); + } + const existingDataservice = dataServiceMap.get(resourceType.value); + + if (hasValue(existingDataservice)) { + throw new Error(`Multiple dataservices for ${resourceType.value}: ${existingDataservice} and ${target}`); } - const metaDataList: string[] = relationshipMap.get(target.constructor) || []; - if (metaDataList.indexOf(propertyKey) === -1) { - metaDataList.push(propertyKey); - } - relationshipMap.set(target.constructor, metaDataList); - return Reflect.metadata(relationshipKey, { - resourceType: (value as any).type.value, - isList - }).apply(this, arguments); + dataServiceMap.set(resourceType.value, target); }; } -export function getRelationMetadata(target: any, propertyKey: string) { - return Reflect.getMetadata(relationshipKey, target, propertyKey); +/** + * Return the dataservice matching the given resource type + * + * @param resourceType the resource type you want the matching dataservice for + */ +export function getDataServiceFor(resourceType: ResourceType) { + return dataServiceMap.get(resourceType.value); } -export function getRelationships(target: any) { - return relationshipMap.get(target); +/** + * A class to represent the data that can be set by the @link decorator + */ +export class LinkDefinition { + resourceType: ResourceType; + isList = false; + linkName: keyof T['_links']; + propertyName: keyof T; +} + +/** + * A property decorator to indicate that a certain property is the placeholder + * where the contents of a resolved link should be stored. + * + * e.g. if an Item has an hal link for bundles, and an item.bundles property + * this decorator should decorate that item.bundles property. + * + * @param resourceType the resource type of the object(s) the link retrieves + * @param isList an optional boolean indicating whether or not it concerns a list, + * defaults to false + * @param linkName an optional string in case the {@link HALLink} name differs from the + * property name + */ +export const link = ( + resourceType: ResourceType, + isList = false, + linkName?: keyof T['_links'], + ) => { + return (target: T, propertyName: string) => { + let targetMap = linkMap.get(target.constructor); + + if (hasNoValue(targetMap)) { + targetMap = new Map>(); + } + + if (hasNoValue(linkName)) { + linkName = propertyName as any; + } + + targetMap.set(linkName, { + resourceType, + isList, + linkName, + propertyName + }); + + linkMap.set(target.constructor, targetMap); + } +}; + +/** + * Returns all LinkDefinitions for a model class + * @param source + */ +export const getLinkDefinitions = (source: GenericConstructor): Map> => { + return linkMap.get(source); +}; + +/** + * Returns a specific LinkDefinition for a model class + * + * @param source the model class + * @param linkName the name of the link + */ +export const getLinkDefinition = (source: GenericConstructor, linkName: keyof T['_links']): LinkDefinition => { + const sourceMap = linkMap.get(source); + if (hasValue(sourceMap)) { + return sourceMap.get(linkName); + } else { + return undefined; + } +}; + +/** + * A class level decorator to indicate you want to inherit @link annotations + * from a parent class. + * + * @param parent the parent class to inherit @link annotations from + */ +export function inheritLinkAnnotations(parent: any): any { + return (child: any) => { + const parentMap: Map> = linkMap.get(parent) || new Map(); + const childMap: Map> = linkMap.get(child) || new Map(); + + parentMap.forEach((value, key) => { + if (!childMap.has(key)) { + childMap.set(key, value); + } + }); + + linkMap.set(child, childMap); + } } diff --git a/src/app/core/cache/builders/link.service.spec.ts b/src/app/core/cache/builders/link.service.spec.ts new file mode 100644 index 0000000000..e9b8447c22 --- /dev/null +++ b/src/app/core/cache/builders/link.service.spec.ts @@ -0,0 +1,269 @@ +import { Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { followLink, FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { FindListOptions } from '../../data/request.models'; +import { HALLink } from '../../shared/hal-link.model'; +import { HALResource } from '../../shared/hal-resource.model'; +import { ResourceType } from '../../shared/resource-type'; +import * as decorators from './build-decorators'; +import { getDataServiceFor } from './build-decorators'; +import { LinkService } from './link.service'; + +const spyOnFunction = (obj: T, func: keyof T) => { + const spy = jasmine.createSpy(func as string); + spyOnProperty(obj, func, 'get').and.returnValue(spy); + + return spy; +}; + +const TEST_MODEL = new ResourceType('testmodel'); +let result: any; + +/* tslint:disable:max-classes-per-file */ +class TestModel implements HALResource { + static type = TEST_MODEL; + + type = TEST_MODEL; + + value: string; + + _links: { + self: HALLink; + predecessor: HALLink; + successor: HALLink; + }; + + predecessor?: TestModel; + successor?: TestModel; +} + +@Injectable() +class TestDataService { + findAllByHref(href: string, findListOptions: FindListOptions = {}, ...linksToFollow: Array>) { + return 'findAllByHref' + } + findByHref(href: string, ...linksToFollow: Array>) { + return 'findByHref' + } +} + +let testDataService: TestDataService; + +let testModel: TestModel; + +describe('LinkService', () => { + let service: LinkService; + + beforeEach(() => { + testModel = Object.assign(new TestModel(), { + value: 'a test value', + _links: { + self: { + href: 'http://self.link' + }, + predecessor: { + href: 'http://predecessor.link' + }, + successor: { + href: 'http://successor.link' + }, + } + }); + testDataService = new TestDataService(); + spyOn(testDataService, 'findAllByHref').and.callThrough(); + spyOn(testDataService, 'findByHref').and.callThrough(); + TestBed.configureTestingModule({ + providers: [LinkService, { + provide: TestDataService, + useValue: testDataService + }] + }); + service = TestBed.get(LinkService); + }); + + describe('resolveLink', () => { + describe(`when the linkdefinition concerns a single object`, () => { + beforeEach(() => { + spyOnFunction(decorators, 'getLinkDefinition').and.returnValue({ + resourceType: TEST_MODEL, + linkName: 'predecessor', + propertyName: 'predecessor' + }); + spyOnFunction(decorators, 'getDataServiceFor').and.returnValue(TestDataService); + 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')); + }); + }); + describe(`when the linkdefinition concerns a list`, () => { + beforeEach(() => { + spyOnFunction(decorators, 'getLinkDefinition').and.returnValue({ + resourceType: TEST_MODEL, + linkName: 'predecessor', + propertyName: 'predecessor', + isList: true + }); + spyOnFunction(decorators, 'getDataServiceFor').and.returnValue(TestDataService); + 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')); + }); + }); + describe('either way', () => { + beforeEach(() => { + spyOnFunction(decorators, 'getLinkDefinition').and.returnValue({ + resourceType: TEST_MODEL, + linkName: 'predecessor', + propertyName: 'predecessor' + }); + spyOnFunction(decorators, 'getDataServiceFor').and.returnValue(TestDataService); + result = service.resolveLink(testModel, followLink('predecessor', {}, true, followLink('successor'))) + }); + + it('should call getLinkDefinition with the correct model and link', () => { + expect(decorators.getLinkDefinition).toHaveBeenCalledWith(testModel.constructor as any, 'predecessor'); + }); + + it('should call getDataServiceFor with the correct resource type', () => { + expect(decorators.getDataServiceFor).toHaveBeenCalledWith(TEST_MODEL); + }); + + it('should return the model with the resolved link', () => { + expect(result.type).toBe(TEST_MODEL); + expect(result.value).toBe('a test value'); + expect(result._links.self.href).toBe('http://self.link'); + expect(result.predecessor).toBe('findByHref'); + }); + }); + + describe(`when the specified link doesn't exist on the model's class`, () => { + beforeEach(() => { + spyOnFunction(decorators, 'getLinkDefinition').and.returnValue(undefined); + }); + it('should throw an error', () => { + expect(() => { + service.resolveLink(testModel, followLink('predecessor', {}, true, followLink('successor'))) + }).toThrow(); + }); + }); + + describe(`when there is no dataservice for the resourcetype in the link`, () => { + beforeEach(() => { + spyOnFunction(decorators, 'getLinkDefinition').and.returnValue({ + resourceType: TEST_MODEL, + linkName: 'predecessor', + propertyName: 'predecessor' + }); + spyOnFunction(decorators, 'getDataServiceFor').and.returnValue(undefined); + }); + it('should throw an error', () => { + expect(() => { + service.resolveLink(testModel, followLink('predecessor', {}, true, followLink('successor'))) + }).toThrow(); + }); + }); + }); + + describe('resolveLinks', () => { + beforeEach(() => { + spyOn(service, 'resolveLink'); + service.resolveLinks(testModel, followLink('predecessor'), followLink('successor')) + }); + + it('should call resolveLink with the model for each of the provided links', () => { + expect(service.resolveLink).toHaveBeenCalledWith(testModel, followLink('predecessor')); + expect(service.resolveLink).toHaveBeenCalledWith(testModel, followLink('successor')); + }); + + it('should return the model', () => { + expect(result.type).toBe(TEST_MODEL); + expect(result.value).toBe('a test value'); + expect(result._links.self.href).toBe('http://self.link'); + }); + }); + + describe('removeResolvedLinks', () => { + beforeEach(() => { + testModel.predecessor = 'predecessor value' as any; + testModel.successor = 'successor value' as any; + spyOnFunction(decorators, 'getLinkDefinitions').and.returnValue([ + { + resourceType: TEST_MODEL, + linkName: 'predecessor', + propertyName: 'predecessor', + }, + { + resourceType: TEST_MODEL, + linkName: 'successor', + propertyName: 'successor', + } + ]) + }); + + it('should return a new version of the object without any resolved links', () => { + result = service.removeResolvedLinks(testModel); + expect(result.value).toBe(testModel.value); + expect(result.type).toBe(testModel.type); + expect(result._links).toBe(testModel._links); + expect(result.predecessor).toBeUndefined(); + expect(result.successor).toBeUndefined(); + }); + + it('should leave the original object untouched', () => { + service.removeResolvedLinks(testModel); + expect(testModel.predecessor as any).toBe('predecessor value'); + expect(testModel.successor as any).toBe('successor value'); + }); + }); + + 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 new file mode 100644 index 0000000000..dc65eab68f --- /dev/null +++ b/src/app/core/cache/builders/link.service.ts @@ -0,0 +1,93 @@ +import { Injectable, Injector } from '@angular/core'; +import { hasNoValue, hasValue, isNotEmpty } from '../../../shared/empty.util'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; +import { GenericConstructor } from '../../shared/generic-constructor'; +import { HALResource } from '../../shared/hal-resource.model'; +import { getDataServiceFor, getLinkDefinition, getLinkDefinitions, LinkDefinition } from './build-decorators'; + +/** + * A Service to handle the resolving and removing + * of resolved {@link HALLink}s on HALResources + */ +@Injectable({ + providedIn: 'root' +}) +export class LinkService { + + constructor( + protected parentInjector: Injector, + ) { + } + + /** + * Resolve the given {@link FollowLinkConfig}s for the given model + * + * @param model the {@link HALResource} to resolve the links for + * @param linksToFollow the {@link FollowLinkConfig}s to resolve + */ + public resolveLinks(model: T, ...linksToFollow: Array>): T { + linksToFollow.forEach((linkToFollow: FollowLinkConfig) => { + this.resolveLink(model, linkToFollow); + }); + return model; + } + + /** + * Resolve the given {@link FollowLinkConfig} for the given model + * + * @param model the {@link HALResource} to resolve the link for + * @param linkToFollow the {@link FollowLinkConfig} to resolve + */ + public resolveLink(model, linkToFollow: FollowLinkConfig): T { + const matchingLinkDef = getLinkDefinition(model.constructor, linkToFollow.name); + + if (hasNoValue(matchingLinkDef)) { + throw new Error(`followLink('${linkToFollow.name}') was used for a ${model.constructor.name}, but there is no property on ${model.constructor.name} models with an @link() for ${linkToFollow.name}`); + } else { + const provider = getDataServiceFor(matchingLinkDef.resourceType); + + if (hasNoValue(provider)) { + throw new Error(`The @link() for ${linkToFollow.name} on ${model.constructor.name} models uses the resource type ${matchingLinkDef.resourceType.value.toUpperCase()}, but there is no service with an @dataService(${matchingLinkDef.resourceType.value.toUpperCase()}) annotation in order to retrieve it`); + } + + const service = Injector.create({ + providers: [], + parent: this.parentInjector + }).get(provider); + + 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); + } + } 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; + } + + /** + * Remove any resolved links that the model may have. + * + * @param model the {@link HALResource} to remove the links from + * @returns a copy of the given model, without resolved links. + */ + public removeResolvedLinks(model: T): T { + const result = Object.assign(new (model.constructor as GenericConstructor)(), model); + const linkDefs = getLinkDefinitions(model.constructor as GenericConstructor); + if (isNotEmpty(linkDefs)) { + linkDefs.forEach((linkDef: LinkDefinition) => { + result[linkDef.propertyName] = undefined; + }); + } + return result; + } + +} diff --git a/src/app/core/cache/builders/normalized-object-build.service.ts b/src/app/core/cache/builders/normalized-object-build.service.ts deleted file mode 100644 index 69d7454d2d..0000000000 --- a/src/app/core/cache/builders/normalized-object-build.service.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Injectable } from '@angular/core'; -import { NormalizedObject } from '../models/normalized-object.model'; -import { getMapsToType, getRelationships } from './build-decorators'; -import { hasValue, isNotEmpty } from '../../../shared/empty.util'; -import { CacheableObject, TypedObject } from '../object-cache.reducer'; - -/** - * Return true if halObj has a value for `_links.self` - * - * @param {any} halObj The object to test - */ -export function isRestDataObject(halObj: any): boolean { - return isNotEmpty(halObj._links) && hasValue(halObj._links.self); -} - -/** - * Return true if halObj has a value for `page` and `_embedded` - * - * @param {any} halObj The object to test - */ -export function isRestPaginatedList(halObj: any): boolean { - return hasValue(halObj.page) && hasValue(halObj._embedded); -} - -/** - * A service to turn domain models in to their normalized - * counterparts. - */ -@Injectable() -export class NormalizedObjectBuildService { - - /** - * Returns the normalized model that corresponds to the given domain model - * - * @param {TDomain} domainModel a domain model - */ - normalize(domainModel: T): NormalizedObject { - const normalizedConstructor = getMapsToType((domainModel as any).type); - const relationships = getRelationships(normalizedConstructor) || []; - const normalizedModel = Object.assign({}, domainModel) as any; - relationships.forEach((key: string) => { - if (hasValue(normalizedModel[key])) { - normalizedModel[key] = normalizedModel._links[key]; - } - }); - return normalizedModel; - } -} diff --git a/src/app/core/cache/builders/remote-data-build.service.spec.ts b/src/app/core/cache/builders/remote-data-build.service.spec.ts index 2f0e024521..85267d7f4c 100644 --- a/src/app/core/cache/builders/remote-data-build.service.spec.ts +++ b/src/app/core/cache/builders/remote-data-build.service.spec.ts @@ -1,10 +1,10 @@ -import { RemoteDataBuildService } from './remote-data-build.service'; -import { Item } from '../../shared/item.model'; -import { PaginatedList } from '../../data/paginated-list'; -import { PageInfo } from '../../shared/page-info.model'; -import { RemoteData } from '../../data/remote-data'; import { of as observableOf } from 'rxjs'; import { createSuccessfulRemoteDataObject } from '../../../shared/testing/utils'; +import { PaginatedList } from '../../data/paginated-list'; +import { RemoteData } from '../../data/remote-data'; +import { Item } from '../../shared/item.model'; +import { PageInfo } from '../../shared/page-info.model'; +import { RemoteDataBuildService } from './remote-data-build.service'; const pageInfo = new PageInfo(); const array = [ @@ -37,7 +37,7 @@ describe('RemoteDataBuildService', () => { let service: RemoteDataBuildService; beforeEach(() => { - service = new RemoteDataBuildService(undefined, undefined); + service = new RemoteDataBuildService(undefined, undefined, undefined); }); describe('when toPaginatedList is called', () => { 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 48c5090102..df895e11a2 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -1,36 +1,46 @@ import { Injectable } from '@angular/core'; - import { combineLatest as observableCombineLatest, Observable, of as observableOf, race as observableRace } from 'rxjs'; -import { distinctUntilChanged, flatMap, map, startWith, switchMap, tap } from 'rxjs/operators'; - -import { hasValue, hasValueOperator, isEmpty, isNotEmpty, isNotUndefined } from '../../../shared/empty.util'; +import { distinctUntilChanged, map, startWith, switchMap } from 'rxjs/operators'; +import { + hasValue, + hasValueOperator, + isEmpty, + isNotEmpty, + isNotUndefined +} from '../../../shared/empty.util'; +import { createSuccessfulRemoteDataObject$ } from '../../../shared/testing/utils'; +import { FollowLinkConfig } from '../../../shared/utils/follow-link-config.model'; import { PaginatedList } from '../../data/paginated-list'; import { RemoteData } from '../../data/remote-data'; import { RemoteDataError } from '../../data/remote-data-error'; -import { GetRequest } from '../../data/request.models'; import { RequestEntry } from '../../data/request.reducer'; import { RequestService } from '../../data/request.service'; -import { NormalizedObject } from '../models/normalized-object.model'; -import { ObjectCacheService } from '../object-cache.service'; -import { DSOSuccessResponse, ErrorResponse } from '../response.models'; -import { getMapsTo, getRelationMetadata, getRelationships } from './build-decorators'; -import { PageInfo } from '../../shared/page-info.model'; import { filterSuccessfulResponses, getRequestFromRequestHref, getRequestFromRequestUUID, getResourceLinksFromResponse } from '../../shared/operators'; -import { CacheableObject, TypedObject } from '../object-cache.reducer'; -import { createSuccessfulRemoteDataObject$ } from '../../../shared/testing/utils'; +import { PageInfo } from '../../shared/page-info.model'; +import { CacheableObject } from '../object-cache.reducer'; +import { ObjectCacheService } from '../object-cache.service'; +import { DSOSuccessResponse, ErrorResponse } from '../response.models'; +import { LinkService } from './link.service'; @Injectable() export class RemoteDataBuildService { constructor(protected objectCache: ObjectCacheService, + protected linkService: LinkService, protected requestService: RequestService) { } - buildSingle(href$: string | Observable): Observable> { + /** + * Creates a single {@link RemoteData} object based on the response of a request to the REST server, with a list of + * {@link FollowLinkConfig} that indicate which embedded info should be added to the object + * @param href$ Observable href of object we want to retrieve + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + buildSingle(href$: string | Observable, ...linksToFollow: Array>): Observable> { if (typeof href$ === 'string') { href$ = observableOf(href$); } @@ -70,9 +80,9 @@ export class RemoteDataBuildService { } }), hasValueOperator(), - map((normalized: NormalizedObject) => { - return this.build(normalized); - }), + map((obj: T) => + this.linkService.resolveLinks(obj, ...linksToFollow) + ), startWith(undefined), distinctUntilChanged() ); @@ -86,13 +96,14 @@ export class RemoteDataBuildService { const responsePending = hasValue(reqEntry) && hasValue(reqEntry.responsePending) ? reqEntry.responsePending : false; let isSuccessful: boolean; let error: RemoteDataError; - if (hasValue(reqEntry) && hasValue(reqEntry.response)) { - isSuccessful = reqEntry.response.isSuccessful; - const errorMessage = isSuccessful === false ? (reqEntry.response as ErrorResponse).errorMessage : undefined; + const response = reqEntry ? reqEntry.response : undefined; + if (hasValue(response)) { + isSuccessful = response.isSuccessful; + const errorMessage = isSuccessful === false ? (response as ErrorResponse).errorMessage : undefined; if (hasValue(errorMessage)) { error = new RemoteDataError( - (reqEntry.response as ErrorResponse).statusCode, - (reqEntry.response as ErrorResponse).statusText, + response.statusCode, + response.statusText, errorMessage ); } @@ -102,13 +113,21 @@ export class RemoteDataBuildService { responsePending, isSuccessful, error, - payload + payload, + hasValue(response) ? response.statusCode : undefined + ); }) ); } - buildList(href$: string | Observable): Observable>> { + /** + * Creates a list of {@link RemoteData} objects based on the response of a request to the REST server, with a list of + * {@link FollowLinkConfig} that indicate which embedded info should be added to the objects + * @param href$ Observable href of objects we want to retrieve + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + buildList(href$: string | Observable, ...linksToFollow: Array>): Observable>> { if (typeof href$ === 'string') { href$ = observableOf(href$); } @@ -118,10 +137,10 @@ export class RemoteDataBuildService { getResourceLinksFromResponse(), switchMap((resourceUUIDs: string[]) => { return this.objectCache.getList(resourceUUIDs).pipe( - map((normList: Array>) => { - return normList.map((normalized: NormalizedObject) => { - return this.build(normalized); - }); + map((objs: T[]) => { + return objs.map((obj: T) => + this.linkService.resolveLinks(obj, ...linksToFollow) + ); })); }), startWith([]), @@ -150,54 +169,6 @@ export class RemoteDataBuildService { return this.toRemoteDataObservable(requestEntry$, payload$); } - build(normalized: NormalizedObject): T { - const links: any = {}; - const relationships = getRelationships(normalized.constructor) || []; - - relationships.forEach((relationship: string) => { - let result; - if (hasValue(normalized[relationship])) { - const { resourceType, isList } = getRelationMetadata(normalized, relationship); - const objectList = normalized[relationship].page || normalized[relationship]; - if (typeof objectList !== 'string') { - objectList.forEach((href: string) => { - this.requestService.configure(new GetRequest(this.requestService.generateRequestId(), href)) - }); - - const rdArr = []; - objectList.forEach((href: string) => { - rdArr.push(this.buildSingle(href)); - }); - - if (isList) { - result = this.aggregate(rdArr); - } else if (rdArr.length === 1) { - result = rdArr[0]; - } - } else { - this.requestService.configure(new GetRequest(this.requestService.generateRequestId(), objectList)); - - // The rest API can return a single URL to represent a list of resources (e.g. /items/:id/bitstreams) - // in that case only 1 href will be stored in the normalized obj (so the isArray above fails), - // but it should still be built as a list - if (isList) { - result = this.buildList(objectList); - } else { - result = this.buildSingle(objectList); - } - } - - if (hasValue(normalized[relationship].page)) { - links[relationship] = this.toPaginatedList(result, normalized[relationship].pageInfo); - } else { - links[relationship] = result; - } - } - }); - const domainModel = getMapsTo(normalized.constructor); - return Object.assign(new domainModel(), normalized, links); - } - aggregate(input: Array>>): Observable> { if (isEmpty(input)) { diff --git a/src/app/core/cache/models/items/normalized-item-type.model.ts b/src/app/core/cache/models/items/normalized-item-type.model.ts deleted file mode 100644 index fdb3b9e455..0000000000 --- a/src/app/core/cache/models/items/normalized-item-type.model.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; -import { ItemType } from '../../../shared/item-relationships/item-type.model'; -import { mapsTo } from '../../builders/build-decorators'; -import { NormalizedObject } from '../normalized-object.model'; -import { IDToUUIDSerializer } from '../../id-to-uuid-serializer'; - -/** - * Normalized model class for a DSpace ItemType - */ -@mapsTo(ItemType) -@inheritSerialization(NormalizedObject) -export class NormalizedItemType extends NormalizedObject { - /** - * The label that describes the ResourceType of the Item - */ - @autoserialize - label: string; - - /** - * The identifier of this ItemType - */ - @autoserialize - id: string; - - /** - * The universally unique identifier of this ItemType - */ - @autoserializeAs(new IDToUUIDSerializer(ItemType.type.value), 'id') - uuid: string; -} diff --git a/src/app/core/cache/models/items/normalized-relationship-type.model.ts b/src/app/core/cache/models/items/normalized-relationship-type.model.ts deleted file mode 100644 index 23c3333a9b..0000000000 --- a/src/app/core/cache/models/items/normalized-relationship-type.model.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; -import { RelationshipType } from '../../../shared/item-relationships/relationship-type.model'; -import { ResourceType } from '../../../shared/resource-type'; -import { mapsTo, relationship } from '../../builders/build-decorators'; -import { NormalizedDSpaceObject } from '../normalized-dspace-object.model'; -import { NormalizedObject } from '../normalized-object.model'; -import { IDToUUIDSerializer } from '../../id-to-uuid-serializer'; -import { ItemType } from '../../../shared/item-relationships/item-type.model'; - -/** - * Normalized model class for a DSpace RelationshipType - */ -@mapsTo(RelationshipType) -@inheritSerialization(NormalizedDSpaceObject) -export class NormalizedRelationshipType extends NormalizedObject { - /** - * The identifier of this RelationshipType - */ - @autoserialize - id: string; - - /** - * The label that describes the Relation to the left of this RelationshipType - */ - @autoserialize - leftwardType: string; - - /** - * The maximum amount of Relationships allowed to the left of this RelationshipType - */ - @autoserialize - leftMaxCardinality: number; - - /** - * The minimum amount of Relationships allowed to the left of this RelationshipType - */ - @autoserialize - leftMinCardinality: number; - - /** - * The label that describes the Relation to the right of this RelationshipType - */ - @autoserialize - rightwardType: string; - - /** - * The maximum amount of Relationships allowed to the right of this RelationshipType - */ - @autoserialize - rightMaxCardinality: number; - - /** - * The minimum amount of Relationships allowed to the right of this RelationshipType - */ - @autoserialize - rightMinCardinality: number; - - /** - * The type of Item found to the left of this RelationshipType - */ - @autoserialize - @relationship(ItemType, false) - leftType: string; - - /** - * The type of Item found to the right of this RelationshipType - */ - @autoserialize - @relationship(ItemType, false) - rightType: string; - - /** - * The universally unique identifier of this RelationshipType - */ - @autoserializeAs(new IDToUUIDSerializer(RelationshipType.type.value), 'id') - uuid: string; -} diff --git a/src/app/core/cache/models/items/normalized-relationship.model.ts b/src/app/core/cache/models/items/normalized-relationship.model.ts deleted file mode 100644 index 1c1dcf8d5b..0000000000 --- a/src/app/core/cache/models/items/normalized-relationship.model.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { autoserialize, deserialize, deserializeAs, inheritSerialization } from 'cerialize'; -import { Relationship } from '../../../shared/item-relationships/relationship.model'; -import { mapsTo, relationship } from '../../builders/build-decorators'; -import { NormalizedObject } from '../normalized-object.model'; -import { IDToUUIDSerializer } from '../../id-to-uuid-serializer'; -import { RelationshipType } from '../../../shared/item-relationships/relationship-type.model'; -import { Item } from '../../../shared/item.model'; - -/** - * Normalized model class for a DSpace Relationship - */ -@mapsTo(Relationship) -@inheritSerialization(NormalizedObject) -export class NormalizedRelationship extends NormalizedObject { - - /** - * The identifier of this Relationship - */ - @deserialize - id: string; - - /** - * The item to the left of this relationship - */ - @deserialize - @relationship(Item, false) - leftItem: string; - - /** - * The item to the right of this relationship - */ - @deserialize - @relationship(Item, false) - rightItem: string; - - /** - * The place of the Item to the left side of this Relationship - */ - @autoserialize - leftPlace: number; - - /** - * The place of the Item to the right side of this Relationship - */ - @autoserialize - rightPlace: number; - - /** - * The name variant of the Item to the left side of this Relationship - */ - @autoserialize - leftwardValue: string; - - /** - * The name variant of the Item to the right side of this Relationship - */ - @autoserialize - rightwardValue: string; - - /** - * The type of Relationship - */ - @deserialize - @relationship(RelationshipType, false) - relationshipType: string; - - /** - * The universally unique identifier of this Relationship - */ - @deserializeAs(new IDToUUIDSerializer(Relationship.type.value), 'id') - uuid: string; -} diff --git a/src/app/core/cache/models/normalized-bitstream-format.model.ts b/src/app/core/cache/models/normalized-bitstream-format.model.ts deleted file mode 100644 index 2283ecb368..0000000000 --- a/src/app/core/cache/models/normalized-bitstream-format.model.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; -import { BitstreamFormat } from '../../shared/bitstream-format.model'; - -import { mapsTo } from '../builders/build-decorators'; -import { IDToUUIDSerializer } from '../id-to-uuid-serializer'; -import { NormalizedObject } from './normalized-object.model'; -import { BitstreamFormatSupportLevel } from '../../shared/bitstream-format-support-level'; - -/** - * Normalized model class for a Bitstream Format - */ -@mapsTo(BitstreamFormat) -@inheritSerialization(NormalizedObject) -export class NormalizedBitstreamFormat extends NormalizedObject { - /** - * Short description of this Bitstream Format - */ - @autoserialize - shortDescription: string; - - /** - * Description of this Bitstream Format - */ - @autoserialize - description: string; - - /** - * String representing the MIME type of this Bitstream Format - */ - @autoserialize - mimetype: string; - - /** - * The level of support the system offers for this Bitstream Format - */ - @autoserialize - supportLevel: BitstreamFormatSupportLevel; - - /** - * True if the Bitstream Format is used to store system information, rather than the content of items in the system - */ - @autoserialize - internal: boolean; - - /** - * String representing this Bitstream Format's file extension - */ - @autoserialize - extensions: string[]; - - /** - * Identifier for this Bitstream Format - * Note that this ID is unique for bitstream formats, - * but might not be unique across different object types - */ - @autoserialize - id: string; - - /** - * Universally unique identifier for this Bitstream Format - * Consist of a prefix and the id field to ensure the identifier is unique across all object types - */ - @autoserializeAs(new IDToUUIDSerializer('bitstream-format'), 'id') - uuid: string; -} diff --git a/src/app/core/cache/models/normalized-bitstream.model.ts b/src/app/core/cache/models/normalized-bitstream.model.ts deleted file mode 100644 index a9e389fd41..0000000000 --- a/src/app/core/cache/models/normalized-bitstream.model.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; - -import { NormalizedDSpaceObject } from './normalized-dspace-object.model'; -import { Bitstream } from '../../shared/bitstream.model'; -import { mapsTo, relationship } from '../builders/build-decorators'; -import { Item } from '../../shared/item.model'; -import { BitstreamFormat } from '../../shared/bitstream-format.model'; - -/** - * Normalized model class for a DSpace Bitstream - */ -@mapsTo(Bitstream) -@inheritSerialization(NormalizedDSpaceObject) -export class NormalizedBitstream extends NormalizedDSpaceObject { - /** - * The size of this bitstream in bytes - */ - @autoserialize - sizeBytes: number; - - /** - * The relative path to this Bitstream's file - */ - @autoserialize - content: string; - - /** - * The format of this Bitstream - */ - @autoserialize - @relationship(BitstreamFormat, false) - format: string; - - /** - * The description of this Bitstream - */ - @autoserialize - description: string; - - /** - * An array of Bundles that are direct parents of this Bitstream - */ - @autoserialize - @relationship(Item, true) - parents: string[]; - - /** - * The Bundle that owns this Bitstream - */ - @autoserialize - @relationship(Item, false) - owner: string; - - /** - * The name of the Bundle this Bitstream is part of - */ - @autoserialize - bundleName: string; - -} diff --git a/src/app/core/cache/models/normalized-bundle.model.ts b/src/app/core/cache/models/normalized-bundle.model.ts deleted file mode 100644 index 9582643efb..0000000000 --- a/src/app/core/cache/models/normalized-bundle.model.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; - -import { NormalizedDSpaceObject } from './normalized-dspace-object.model'; -import { Bundle } from '../../shared/bundle.model'; -import { mapsTo, relationship } from '../builders/build-decorators'; -import { Bitstream } from '../../shared/bitstream.model'; - -/** - * Normalized model class for a DSpace Bundle - */ -@mapsTo(Bundle) -@inheritSerialization(NormalizedDSpaceObject) -export class NormalizedBundle extends NormalizedDSpaceObject { - - /** - * The bundle's name - */ - @autoserialize - name: string; - - /** - * The primary bitstream of this Bundle - */ - @autoserialize - @relationship(Bitstream, false) - primaryBitstream: string; - - /** - * An array of Items that are direct parents of this Bundle - */ - parents: string[]; - - /** - * The Item that owns this Bundle - */ - owner: string; - - /** - * List of Bitstreams that are part of this Bundle - */ - @autoserialize - @relationship(Bitstream, true) - bitstreams: string[]; - -} diff --git a/src/app/core/cache/models/normalized-collection.model.ts b/src/app/core/cache/models/normalized-collection.model.ts deleted file mode 100644 index 9b3419675a..0000000000 --- a/src/app/core/cache/models/normalized-collection.model.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; - -import { NormalizedDSpaceObject } from './normalized-dspace-object.model'; -import { Collection } from '../../shared/collection.model'; -import { mapsTo, relationship } from '../builders/build-decorators'; -import { NormalizedResourcePolicy } from './normalized-resource-policy.model'; -import { NormalizedBitstream } from './normalized-bitstream.model'; -import { NormalizedCommunity } from './normalized-community.model'; -import { NormalizedItem } from './normalized-item.model'; -import { License } from '../../shared/license.model'; -import { ResourcePolicy } from '../../shared/resource-policy.model'; -import { Bitstream } from '../../shared/bitstream.model'; -import { Community } from '../../shared/community.model'; -import { Item } from '../../shared/item.model'; - -/** - * Normalized model class for a DSpace Collection - */ -@mapsTo(Collection) -@inheritSerialization(NormalizedDSpaceObject) -export class NormalizedCollection extends NormalizedDSpaceObject { - - /** - * A string representing the unique handle of this Collection - */ - @autoserialize - handle: string; - - /** - * The Bitstream that represents the license of this Collection - */ - @autoserialize - @relationship(License, false) - license: string; - - /** - * The Bitstream that represents the default Access Conditions of this Collection - */ - @autoserialize - @relationship(ResourcePolicy, false) - defaultAccessConditions: string; - - /** - * The Bitstream that represents the logo of this Collection - */ - @deserialize - @relationship(Bitstream, false) - logo: string; - - /** - * An array of Communities that are direct parents of this Collection - */ - @deserialize - @relationship(Community, true) - parents: string[]; - - /** - * The Community that owns this Collection - */ - @deserialize - @relationship(Community, false) - owner: string; - - /** - * List of Items that are part of (not necessarily owned by) this Collection - */ - @deserialize - @relationship(Item, true) - items: string[]; - -} diff --git a/src/app/core/cache/models/normalized-community.model.ts b/src/app/core/cache/models/normalized-community.model.ts deleted file mode 100644 index 173760ca72..0000000000 --- a/src/app/core/cache/models/normalized-community.model.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; - -import { NormalizedDSpaceObject } from './normalized-dspace-object.model'; -import { Community } from '../../shared/community.model'; -import { mapsTo, relationship } from '../builders/build-decorators'; -import { ResourceType } from '../../shared/resource-type'; -import { NormalizedBitstream } from './normalized-bitstream.model'; -import { NormalizedCollection } from './normalized-collection.model'; -import { Bitstream } from '../../shared/bitstream.model'; -import { Collection } from '../../shared/collection.model'; - -/** - * Normalized model class for a DSpace Community - */ -@mapsTo(Community) -@inheritSerialization(NormalizedDSpaceObject) -export class NormalizedCommunity extends NormalizedDSpaceObject { - /** - * A string representing the unique handle of this Community - */ - @autoserialize - handle: string; - - /** - * The Bitstream that represents the logo of this Community - */ - @deserialize - @relationship(Bitstream, false) - logo: string; - - /** - * An array of Communities that are direct parents of this Community - */ - @deserialize - @relationship(Community, true) - parents: string[]; - - /** - * The Community that owns this Community - */ - @deserialize - @relationship(Community, false) - owner: string; - - /** - * List of Collections that are owned by this Community - */ - @deserialize - @relationship(Collection, true) - collections: string[]; - - @deserialize - @relationship(Community, true) - subcommunities: string[]; - -} diff --git a/src/app/core/cache/models/normalized-dspace-object.model.ts b/src/app/core/cache/models/normalized-dspace-object.model.ts deleted file mode 100644 index 3c43dd85dc..0000000000 --- a/src/app/core/cache/models/normalized-dspace-object.model.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { autoserializeAs, deserializeAs, autoserialize } from 'cerialize'; -import { DSpaceObject } from '../../shared/dspace-object.model'; -import { MetadataMap, MetadataMapSerializer } from '../../shared/metadata.models'; -import { mapsTo } from '../builders/build-decorators'; -import { NormalizedObject } from './normalized-object.model'; -import { TypedObject } from '../object-cache.reducer'; - -/** - * An model class for a DSpaceObject. - */ -@mapsTo(DSpaceObject) -export class NormalizedDSpaceObject extends NormalizedObject implements TypedObject { - - /** - * The link to the rest endpoint where this object can be found - * - * Repeated here to make the serialization work, - * inheritSerialization doesn't seem to work for more than one level - */ - @deserializeAs(String) - self: string; - - /** - * The human-readable identifier of this DSpaceObject - * - * Currently mapped to uuid but left in to leave room - * for a shorter, more user friendly type of id - */ - @autoserializeAs(String, 'uuid') - id: string; - - /** - * The universally unique identifier of this DSpaceObject - */ - @autoserializeAs(String) - uuid: string; - - /** - * A string representing the kind of DSpaceObject, e.g. community, item, … - */ - @autoserialize - type: string; - - /** - * All metadata of this DSpaceObject - */ - @autoserializeAs(MetadataMapSerializer) - metadata: MetadataMap; - - /** - * An array of DSpaceObjects that are direct parents of this DSpaceObject - */ - @deserializeAs(String) - parents: string[]; - - /** - * The DSpaceObject that owns this DSpaceObject - */ - @deserializeAs(String) - owner: string; - - /** - * The links to all related resources returned by the rest api. - * - * Repeated here to make the serialization work, - * inheritSerialization doesn't seem to work for more than one level - */ - @deserializeAs(Object) - _links: { - [name: string]: string - } -} diff --git a/src/app/core/cache/models/normalized-external-source-entry.model.ts b/src/app/core/cache/models/normalized-external-source-entry.model.ts deleted file mode 100644 index e8e3c695c3..0000000000 --- a/src/app/core/cache/models/normalized-external-source-entry.model.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; -import { NormalizedObject } from './normalized-object.model'; -import { ExternalSourceEntry } from '../../shared/external-source-entry.model'; -import { mapsTo } from '../builders/build-decorators'; -import { MetadataMap, MetadataMapSerializer } from '../../shared/metadata.models'; - -/** - * Normalized model class for an external source entry - */ -@mapsTo(ExternalSourceEntry) -@inheritSerialization(NormalizedObject) -export class NormalizedExternalSourceEntry extends NormalizedObject { - /** - * Unique identifier - */ - @autoserialize - id: string; - - /** - * The value to display - */ - @autoserialize - display: string; - - /** - * The value to store the entry with - */ - @autoserialize - value: string; - - /** - * Metadata of the entry - */ - @autoserializeAs(MetadataMapSerializer) - metadata: MetadataMap; -} diff --git a/src/app/core/cache/models/normalized-external-source.model.ts b/src/app/core/cache/models/normalized-external-source.model.ts deleted file mode 100644 index fd9a42fb72..0000000000 --- a/src/app/core/cache/models/normalized-external-source.model.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; -import { NormalizedObject } from './normalized-object.model'; -import { ExternalSource } from '../../shared/external-source.model'; -import { mapsTo } from '../builders/build-decorators'; - -/** - * Normalized model class for an external source - */ -@mapsTo(ExternalSource) -@inheritSerialization(NormalizedObject) -export class NormalizedExternalSource extends NormalizedObject { - /** - * Unique identifier - */ - @autoserialize - id: string; - - /** - * The name of this external source - */ - @autoserialize - name: string; - - /** - * Is the source hierarchical? - */ - @autoserialize - hierarchical: boolean; -} diff --git a/src/app/core/cache/models/normalized-item.model.ts b/src/app/core/cache/models/normalized-item.model.ts deleted file mode 100644 index 9b7edf70c0..0000000000 --- a/src/app/core/cache/models/normalized-item.model.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { inheritSerialization, deserialize, autoserialize, autoserializeAs } from 'cerialize'; - -import { NormalizedDSpaceObject } from './normalized-dspace-object.model'; -import { Item } from '../../shared/item.model'; -import { mapsTo, relationship } from '../builders/build-decorators'; -import { Collection } from '../../shared/collection.model'; -import { Relationship } from '../../shared/item-relationships/relationship.model'; -import { Bundle } from '../../shared/bundle.model'; - -/** - * Normalized model class for a DSpace Item - */ -@mapsTo(Item) -@inheritSerialization(NormalizedDSpaceObject) -export class NormalizedItem extends NormalizedDSpaceObject { - - /** - * A string representing the unique handle of this Item - */ - @autoserialize - handle: string; - - /** - * The Date of the last modification of this Item - */ - @deserialize - lastModified: Date; - - /** - * A boolean representing if this Item is currently archived or not - */ - @autoserializeAs(Boolean, 'inArchive') - isArchived: boolean; - - /** - * A boolean representing if this Item is currently discoverable or not - */ - @autoserializeAs(Boolean, 'discoverable') - isDiscoverable: boolean; - - /** - * A boolean representing if this Item is currently withdrawn or not - */ - @autoserializeAs(Boolean, 'withdrawn') - isWithdrawn: boolean; - - /** - * An array of Collections that are direct parents of this Item - */ - @deserialize - @relationship(Collection, true) - parents: string[]; - - /** - * The Collection that owns this Item - */ - @deserialize - @relationship(Collection, false) - owningCollection: string; - - /** - * List of Bitstreams that are owned by this Item - */ - @deserialize - @relationship(Bundle, true) - bundles: string[]; - - @deserialize - @relationship(Relationship, true) - relationships: string[]; - -} diff --git a/src/app/core/cache/models/normalized-license.model.ts b/src/app/core/cache/models/normalized-license.model.ts deleted file mode 100644 index 02bd1808c8..0000000000 --- a/src/app/core/cache/models/normalized-license.model.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; -import { mapsTo } from '../builders/build-decorators'; -import { NormalizedDSpaceObject } from './normalized-dspace-object.model'; -import { License } from '../../shared/license.model'; - -/** - * Normalized model class for a Collection License - */ -@mapsTo(License) -@inheritSerialization(NormalizedDSpaceObject) -export class NormalizedLicense extends NormalizedDSpaceObject { - - /** - * A boolean representing if this License is custom or not - */ - @autoserialize - custom: boolean; - - /** - * The text of the license - */ - @autoserialize - text: string; -} diff --git a/src/app/core/cache/models/normalized-object.model.ts b/src/app/core/cache/models/normalized-object.model.ts deleted file mode 100644 index 8a3aed32c9..0000000000 --- a/src/app/core/cache/models/normalized-object.model.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { CacheableObject, TypedObject } from '../object-cache.reducer'; -import { autoserialize, deserialize } from 'cerialize'; -import { ResourceType } from '../../shared/resource-type'; -/** - * An abstract model class for a NormalizedObject. - */ -export abstract class NormalizedObject implements CacheableObject { - /** - * The link to the rest endpoint where this object can be found - */ - @deserialize - self: string; - - @deserialize - _links: { - [name: string]: string - }; - - /** - * A string representing the kind of object - */ - @deserialize - type: string; -} diff --git a/src/app/core/cache/models/normalized-resource-policy.model.ts b/src/app/core/cache/models/normalized-resource-policy.model.ts deleted file mode 100644 index cd25a0af05..0000000000 --- a/src/app/core/cache/models/normalized-resource-policy.model.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; -import { ResourcePolicy } from '../../shared/resource-policy.model'; - -import { mapsTo } from '../builders/build-decorators'; -import { NormalizedObject } from './normalized-object.model'; -import { IDToUUIDSerializer } from '../id-to-uuid-serializer'; -import { ActionType } from './action-type.model'; - -/** - * Normalized model class for a Resource Policy - */ -@mapsTo(ResourcePolicy) -@inheritSerialization(NormalizedObject) -export class NormalizedResourcePolicy extends NormalizedObject { - /** - * The action that is allowed by this Resource Policy - */ - @autoserialize - action: ActionType; - - /** - * The name for this Resource Policy - */ - @autoserialize - name: string; - - /** - * The uuid of the Group this Resource Policy applies to - */ - @autoserialize - groupUUID: string; - - /** - * Identifier for this Resource Policy - * Note that this ID is unique for resource policies, - * but might not be unique across different object types - */ - @autoserialize - id: string; - - /** - * The universally unique identifier for this Resource Policy - * Consist of a prefix and the id field to ensure the identifier is unique across all object types - */ - @autoserializeAs(new IDToUUIDSerializer('resource-policy'), 'id') - uuid: string; - -} diff --git a/src/app/core/cache/models/normalized-site.model.ts b/src/app/core/cache/models/normalized-site.model.ts deleted file mode 100644 index 68a7e0a480..0000000000 --- a/src/app/core/cache/models/normalized-site.model.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { inheritSerialization } from 'cerialize'; -import { NormalizedDSpaceObject } from './normalized-dspace-object.model'; -import { mapsTo } from '../builders/build-decorators'; -import { Site } from '../../shared/site.model'; - -/** - * Normalized model class for a Site object - */ -@mapsTo(Site) -@inheritSerialization(NormalizedDSpaceObject) -export class NormalizedSite extends NormalizedDSpaceObject { - -} diff --git a/src/app/core/cache/object-cache.reducer.spec.ts b/src/app/core/cache/object-cache.reducer.spec.ts index a65e63ab86..6519e887c9 100644 --- a/src/app/core/cache/object-cache.reducer.spec.ts +++ b/src/app/core/cache/object-cache.reducer.spec.ts @@ -1,6 +1,6 @@ import * as deepFreeze from 'deep-freeze'; - -import { objectCacheReducer } from './object-cache.reducer'; +import { Operation } from 'fast-json-patch'; +import { Item } from '../shared/item.model'; import { AddPatchObjectCacheAction, AddToObjectCacheAction, @@ -8,8 +8,8 @@ import { RemoveFromObjectCacheAction, ResetObjectCacheTimestampsAction } from './object-cache.actions'; -import { Operation } from 'fast-json-patch'; -import { Item } from '../shared/item.model'; + +import { objectCacheReducer } from './object-cache.reducer'; class NullAction extends RemoveFromObjectCacheAction { type = null; @@ -31,19 +31,21 @@ describe('objectCacheReducer', () => { data: { type: Item.type, self: selfLink1, - foo: 'bar' + foo: 'bar', + _links: { self: { href: selfLink1 } } }, timeAdded: new Date().getTime(), msToLive: 900000, requestUUID: requestUUID1, patches: [], - isDirty: false + isDirty: false, }, [selfLink2]: { data: { type: Item.type, self: requestUUID2, - foo: 'baz' + foo: 'baz', + _links: { self: { href: requestUUID2 } } }, timeAdded: new Date().getTime(), msToLive: 900000, @@ -70,7 +72,7 @@ describe('objectCacheReducer', () => { it('should add the payload to the cache in response to an ADD action', () => { const state = Object.create(null); - const objectToCache = { self: selfLink1, type: Item.type }; + const objectToCache = { self: selfLink1, type: Item.type, _links: { self: { href: selfLink1 } } }; const timeAdded = new Date().getTime(); const msToLive = 900000; const requestUUID = requestUUID1; @@ -87,7 +89,8 @@ describe('objectCacheReducer', () => { self: selfLink1, foo: 'baz', somethingElse: true, - type: Item.type + type: Item.type, + _links: { self: { href: selfLink1 } } }; const timeAdded = new Date().getTime(); const msToLive = 900000; @@ -103,7 +106,7 @@ describe('objectCacheReducer', () => { it('should perform the ADD action without affecting the previous state', () => { const state = Object.create(null); - const objectToCache = { self: selfLink1, type: Item.type }; + const objectToCache = { self: selfLink1, type: Item.type, _links: { self: { href: selfLink1 } } }; const timeAdded = new Date().getTime(); const msToLive = 900000; const requestUUID = requestUUID1; @@ -121,8 +124,8 @@ describe('objectCacheReducer', () => { expect(newState[selfLink1]).toBeUndefined(); }); - it("shouldn't do anything in response to the REMOVE action for an object that isn't cached", () => { - const wrongKey = "this isn't cached"; + it('shouldn\'t do anything in response to the REMOVE action for an object that isn\'t cached', () => { + const wrongKey = 'this isn\'t cached'; const action = new RemoveFromObjectCacheAction(wrongKey); const newState = objectCacheReducer(testState, action); diff --git a/src/app/core/cache/object-cache.reducer.ts b/src/app/core/cache/object-cache.reducer.ts index afc040bf59..a39ceb4e16 100644 --- a/src/app/core/cache/object-cache.reducer.ts +++ b/src/app/core/cache/object-cache.reducer.ts @@ -1,3 +1,7 @@ +import { autoserialize, deserialize } from 'cerialize'; +import { HALLink } from '../shared/hal-link.model'; +import { HALResource } from '../shared/hal-resource.model'; +import { excludeFromEquals } from '../utilities/equals.decorators'; import { ObjectCacheAction, ObjectCacheActionTypes, @@ -34,6 +38,7 @@ export interface Patch { export abstract class TypedObject { static type: ResourceType; + type: ResourceType; } /* tslint:disable:max-classes-per-file */ @@ -42,10 +47,13 @@ export abstract class TypedObject { * * A cacheable object should have a self link */ -export class CacheableObject extends TypedObject { +export class CacheableObject extends TypedObject implements HALResource { uuid?: string; handle?: string; - self: string; + + _links: { + self: HALLink; + } // isNew: boolean; // dirtyType: DirtyType; // hasDirtyAttributes: boolean; @@ -129,9 +137,9 @@ export function objectCacheReducer(state = initialState, action: ObjectCacheActi * the new state, with the object added, or overwritten. */ function addToObjectCache(state: ObjectCacheState, action: AddToObjectCacheAction): ObjectCacheState { - const existing = state[action.payload.objectToCache.self]; + const existing = state[action.payload.objectToCache._links.self.href]; return Object.assign({}, state, { - [action.payload.objectToCache.self]: { + [action.payload.objectToCache._links.self.href]: { data: action.payload.objectToCache, timeAdded: action.payload.timeAdded, msToLive: action.payload.msToLive, diff --git a/src/app/core/cache/object-cache.service.spec.ts b/src/app/core/cache/object-cache.service.spec.ts index 39dc10de2c..e7c208e095 100644 --- a/src/app/core/cache/object-cache.service.spec.ts +++ b/src/app/core/cache/object-cache.service.spec.ts @@ -1,34 +1,36 @@ import * as ngrx from '@ngrx/store'; import { Store } from '@ngrx/store'; +import { Operation } from 'fast-json-patch'; import { of as observableOf } from 'rxjs'; - -import { ObjectCacheService } from './object-cache.service'; +import { first } from 'rxjs/operators'; +import { CoreState } from '../core.reducers'; +import { RestRequestMethod } from '../data/rest-request-method'; +import { Item } from '../shared/item.model'; import { AddPatchObjectCacheAction, AddToObjectCacheAction, ApplyPatchObjectCacheAction, RemoveFromObjectCacheAction } from './object-cache.actions'; -import { CoreState } from '../core.reducers'; -import { NormalizedItem } from './models/normalized-item.model'; -import { first } from 'rxjs/operators'; -import { Operation } from 'fast-json-patch'; -import { RestRequestMethod } from '../data/rest-request-method'; -import { AddToSSBAction } from './server-sync-buffer.actions'; import { Patch } from './object-cache.reducer'; -import { Item } from '../shared/item.model'; + +import { ObjectCacheService } from './object-cache.service'; +import { AddToSSBAction } from './server-sync-buffer.actions'; describe('ObjectCacheService', () => { let service: ObjectCacheService; let store: Store; + let linkServiceStub; const selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; const requestUUID = '4d3a4ce8-a375-4b98-859b-39f0a014d736'; const timestamp = new Date().getTime(); const msToLive = 900000; let objectToCache = { - self: selfLink, - type: Item.type + type: Item.type, + _links: { + self: { href: selfLink } + } }; let cacheEntry; let invalidCacheEntry; @@ -36,8 +38,10 @@ describe('ObjectCacheService', () => { function init() { objectToCache = { - self: selfLink, - type: Item.type + type: Item.type, + _links: { + self: { href: selfLink } + } }; cacheEntry = { data: objectToCache, @@ -50,8 +54,12 @@ describe('ObjectCacheService', () => { beforeEach(() => { init(); store = new Store(undefined, undefined, undefined); + linkServiceStub = { + removeResolvedLinks: (a) => a + }; + spyOn(linkServiceStub, 'removeResolvedLinks').and.callThrough(); spyOn(store, 'dispatch'); - service = new ObjectCacheService(store); + service = new ObjectCacheService(store, linkServiceStub); spyOn(Date.prototype, 'getTime').and.callFake(() => { return timestamp; @@ -62,6 +70,7 @@ describe('ObjectCacheService', () => { it('should dispatch an ADD action with the object to add, the time to live, and the current timestamp', () => { service.add(objectToCache, msToLive, requestUUID); expect(store.dispatch).toHaveBeenCalledWith(new AddToObjectCacheAction(objectToCache, timestamp, msToLive, requestUUID)); + expect(linkServiceStub.removeResolvedLinks).toHaveBeenCalledWith(objectToCache); }); }); @@ -82,9 +91,9 @@ describe('ObjectCacheService', () => { // due to the implementation of spyOn above, this subscribe will be synchronous service.getObjectBySelfLink(selfLink).pipe(first()).subscribe((o) => { - expect(o.self).toBe(selfLink); + expect(o._links.self.href).toBe(selfLink); // this only works if testObj is an instance of TestClass - expect(o instanceof NormalizedItem).toBeTruthy(); + expect(o instanceof Item).toBeTruthy(); } ); }); @@ -105,13 +114,14 @@ describe('ObjectCacheService', () => { describe('getList', () => { it('should return an observable of the array of cached objects with the specified self link and type', () => { - const item = new NormalizedItem(); - item.self = selfLink; + const item = Object.assign(new Item(), { + _links: { self: { href: selfLink } } + }); spyOn(service, 'getObjectBySelfLink').and.returnValue(observableOf(item)); service.getList([selfLink, selfLink]).pipe(first()).subscribe((arr) => { - expect(arr[0].self).toBe(selfLink); - expect(arr[0] instanceof NormalizedItem).toBeTruthy(); + expect(arr[0]._links.self.href).toBe(selfLink); + expect(arr[0] instanceof Item).toBeTruthy(); }); }); }); @@ -127,7 +137,7 @@ describe('ObjectCacheService', () => { expect(service.hasBySelfLink(selfLink)).toBe(true); }); - it("should return false if the object with the supplied self link isn't cached", () => { + it('should return false if the object with the supplied self link isn\'t cached', () => { spyOnProperty(ngrx, 'select').and.callFake(() => { return () => { return () => observableOf(undefined); diff --git a/src/app/core/cache/object-cache.service.ts b/src/app/core/cache/object-cache.service.ts index 8d4e910471..d82a1f31fe 100644 --- a/src/app/core/cache/object-cache.service.ts +++ b/src/app/core/cache/object-cache.service.ts @@ -10,7 +10,8 @@ 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 { NormalizedObject } from './models/normalized-object.model'; +import { getClassForType } from './builders/build-decorators'; +import { LinkService } from './builders/link.service'; import { AddPatchObjectCacheAction, AddToObjectCacheAction, @@ -20,7 +21,6 @@ import { import { CacheableObject, ObjectCacheEntry, ObjectCacheState } from './object-cache.reducer'; import { AddToSSBAction } from './server-sync-buffer.actions'; -import { getMapsToType } from './builders/build-decorators'; /** * The base selector function to select the object cache in the store @@ -45,21 +45,25 @@ const entryFromSelfLinkSelector = */ @Injectable() export class ObjectCacheService { - constructor(private store: Store) { + constructor( + private store: Store, + private linkService: LinkService + ) { } /** * Add an object to the cache * - * @param objectToCache + * @param object * The object to add * @param msToLive * The number of milliseconds it should be cached for * @param requestUUID * The UUID of the request that resulted in this object */ - add(objectToCache: CacheableObject, msToLive: number, requestUUID: string): void { - this.store.dispatch(new AddToObjectCacheAction(objectToCache, new Date().getTime(), msToLive, requestUUID)); + add(object: CacheableObject, msToLive: number, requestUUID: string): void { + object = this.linkService.removeResolvedLinks(object); // Ensure the object we're storing has no resolved links + this.store.dispatch(new AddToObjectCacheAction(object, new Date().getTime(), msToLive, requestUUID)); } /** @@ -77,14 +81,14 @@ export class ObjectCacheService { * * @param uuid * The UUID of the object to get - * @return Observable> - * An observable of the requested object in normalized form + * @return Observable + * An observable of the requested object */ getObjectByUUID(uuid: string): - Observable> { + Observable { return this.store.pipe( select(selfLinkFromUuidSelector(uuid)), - mergeMap((selfLink: string) => this.getObjectBySelfLink(selfLink) + mergeMap((selfLink: string) => this.getObjectBySelfLink(selfLink) ) ) } @@ -94,10 +98,10 @@ export class ObjectCacheService { * * @param selfLink * The selfLink of the object to get - * @return Observable> - * An observable of the requested object in normalized form + * @return Observable + * An observable of the requested object */ - getObjectBySelfLink(selfLink: string): Observable> { + getObjectBySelfLink(selfLink: string): Observable { return this.getBySelfLink(selfLink).pipe( map((entry: ObjectCacheEntry) => { if (isNotEmpty(entry.patches)) { @@ -110,8 +114,11 @@ export class ObjectCacheService { } ), map((entry: ObjectCacheEntry) => { - const type: GenericConstructor> = getMapsToType((entry.data as any).type); - return Object.assign(new type(), entry.data) as NormalizedObject + const type: GenericConstructor = getClassForType((entry.data as any).type); + if (typeof type !== 'function') { + throw new Error(`${type} is not a valid constructor for ${JSON.stringify(entry.data)}`); + } + return Object.assign(new type(), entry.data) as T }) ); } @@ -180,7 +187,7 @@ export class ObjectCacheService { * The type of the objects to get * @return Observable> */ - getList(selfLinks: string[]): Observable>> { + getList(selfLinks: string[]): Observable { return observableCombineLatest( selfLinks.map((selfLink: string) => this.getObjectBySelfLink(selfLink)) ); @@ -254,7 +261,7 @@ export class ObjectCacheService { const timeOutdated = entry.timeAdded + entry.msToLive; const isOutDated = new Date().getTime() > timeOutdated; if (isOutDated) { - this.store.dispatch(new RemoveFromObjectCacheAction(entry.data.self)); + this.store.dispatch(new RemoveFromObjectCacheAction(entry.data._links.self.href)); } return !isOutDated; } diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts index 3915eca23f..3f46ecf647 100644 --- a/src/app/core/cache/response.models.ts +++ b/src/app/core/cache/response.models.ts @@ -1,4 +1,5 @@ import { SearchQueryResponse } from '../../shared/search/search-query-response.model'; +import { AuthStatus } from '../auth/models/auth-status.model'; import { RequestError } from '../data/request.models'; import { PageInfo } from '../shared/page-info.model'; import { ConfigObject } from '../config/models/config.model'; @@ -11,9 +12,9 @@ import { RegistryBitstreamformatsResponse } from '../registry/registry-bitstream import { PaginatedList } from '../data/paginated-list'; import { SubmissionObject } from '../submission/models/submission-object.model'; import { DSpaceObject } from '../shared/dspace-object.model'; -import { NormalizedAuthStatus } from '../auth/models/normalized-auth-status.model'; import { MetadataSchema } from '../metadata/metadata-schema.model'; import { MetadataField } from '../metadata/metadata-field.model'; +import { ContentSource } from '../shared/content-source.model'; /* tslint:disable:max-classes-per-file */ export class RestResponse { @@ -202,7 +203,7 @@ export class AuthStatusResponse extends RestResponse { public toCache = false; constructor( - public response: NormalizedAuthStatus, + public response: AuthStatus, public statusCode: number, public statusText: string, ) { @@ -288,4 +289,17 @@ export class FilteredDiscoveryQueryResponse extends RestResponse { super(true, statusCode, statusText); } } + +/** + * A successful response containing exactly one MetadataSchema + */ +export class ContentSourceSuccessResponse extends RestResponse { + constructor( + public contentsource: ContentSource, + public statusCode: number, + public statusText: string, + ) { + super(true, statusCode, statusText); + } +} /* tslint:enable:max-classes-per-file */ 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 66477adc20..e58e8406ed 100644 --- a/src/app/core/cache/server-sync-buffer.effects.spec.ts +++ b/src/app/core/cache/server-sync-buffer.effects.spec.ts @@ -1,22 +1,22 @@ import { TestBed } from '@angular/core/testing'; - -import { Observable, of as observableOf } from 'rxjs'; import { provideMockActions } from '@ngrx/effects/testing'; +import { Store, StoreModule } from '@ngrx/store'; import { cold, hot } from 'jasmine-marbles'; -import { ServerSyncBufferEffects } from './server-sync-buffer.effects'; -import { GLOBAL_CONFIG } from '../../../config'; -import { CommitSSBAction, EmptySSBAction, ServerSyncBufferActionTypes } from './server-sync-buffer.actions'; -import { RestRequestMethod } from '../data/rest-request-method'; -import { Store, StoreModule } from '@ngrx/store'; -import { RequestService } from '../data/request.service'; -import { ObjectCacheService } from './object-cache.service'; -import { MockStore } from '../../shared/testing/mock-store'; +import { Observable, of as observableOf } from 'rxjs'; import * as operators from 'rxjs/operators'; -import { spyOnOperator } from '../../shared/testing/utils'; -import { DSpaceObject } from '../shared/dspace-object.model'; +import { GLOBAL_CONFIG } from '../../../config'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { MockStore } from '../../shared/testing/mock-store'; +import { spyOnOperator } from '../../shared/testing/utils'; +import { RequestService } from '../data/request.service'; +import { RestRequestMethod } from '../data/rest-request-method'; +import { DSpaceObject } from '../shared/dspace-object.model'; import { ApplyPatchObjectCacheAction } from './object-cache.actions'; +import { ObjectCacheService } from './object-cache.service'; +import { CommitSSBAction, EmptySSBAction, ServerSyncBufferActionTypes } from './server-sync-buffer.actions'; + +import { ServerSyncBufferEffects } from './server-sync-buffer.effects'; describe('ServerSyncBufferEffects', () => { let ssbEffects: ServerSyncBufferEffects; @@ -47,8 +47,17 @@ describe('ServerSyncBufferEffects', () => { { provide: ObjectCacheService, useValue: { getObjectBySelfLink: (link) => { - const object = new DSpaceObject(); - object.self = link; + const object = Object.assign(new DSpaceObject(), { + _links: { self: { href: link } } + }); + return observableOf(object); + }, + getBySelfLink: (link) => { + const object = Object.assign(new DSpaceObject(), { + _links: { + self: { href: link } + } + }); return observableOf(object); }, getBySelfLink: (link) => { diff --git a/src/app/core/cache/server-sync-buffer.effects.ts b/src/app/core/cache/server-sync-buffer.effects.ts index 61f758e5a4..84f0312385 100644 --- a/src/app/core/cache/server-sync-buffer.effects.ts +++ b/src/app/core/cache/server-sync-buffer.effects.ts @@ -15,16 +15,14 @@ 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 { PatchRequest, PutRequest } from '../data/request.models'; +import { PatchRequest } from '../data/request.models'; import { ObjectCacheService } from './object-cache.service'; import { ApplyPatchObjectCacheAction } from './object-cache.actions'; -import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; -import { GenericConstructor } from '../shared/generic-constructor'; import { hasValue, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; import { Observable } from 'rxjs/internal/Observable'; import { RestRequestMethod } from '../data/rest-request-method'; -import { Operation } from 'fast-json-patch'; import { ObjectCacheEntry } from './object-cache.reducer'; +import { Operation } from 'fast-json-patch'; @Injectable() export class ServerSyncBufferEffects { @@ -104,14 +102,13 @@ export class ServerSyncBufferEffects { map((entry: ObjectCacheEntry) => { if (isNotEmpty(entry.patches)) { const flatPatch: Operation[] = [].concat(...entry.patches.map((patch) => patch.operations)); - const objectPatch = flatPatch.filter((op: Operation) => op.path.startsWith('/metadata')); - if (isNotEmpty(objectPatch)) { - this.requestService.configure(new PatchRequest(this.requestService.generateRequestId(), href, objectPatch)); + 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/cache/server-sync-buffer.reducer.ts b/src/app/core/cache/server-sync-buffer.reducer.ts index c86a0d5654..d79dd51da4 100644 --- a/src/app/core/cache/server-sync-buffer.reducer.ts +++ b/src/app/core/cache/server-sync-buffer.reducer.ts @@ -68,6 +68,8 @@ function addToServerSyncQueue(state: ServerSyncBufferState, action: AddToSSBActi const actionEntry = action.payload as ServerSyncBufferEntry; if (hasNoValue(state.buffer.find((entry) => entry.href === actionEntry.href && entry.method === actionEntry.method))) { return Object.assign({}, state, { buffer: state.buffer.concat(actionEntry) }); + } else { + return state; } } diff --git a/src/app/core/config/config-response-parsing.service.spec.ts b/src/app/core/config/config-response-parsing.service.spec.ts index 90dd1670b8..87a7057078 100644 --- a/src/app/core/config/config-response-parsing.service.spec.ts +++ b/src/app/core/config/config-response-parsing.service.spec.ts @@ -1,22 +1,21 @@ -import { ConfigSuccessResponse, ErrorResponse } from '../cache/response.models'; -import { ConfigResponseParsingService } from './config-response-parsing.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { GlobalConfig } from '../../../config/global-config.interface'; -import { ConfigRequest } from '../data/request.models'; - import { Store } from '@ngrx/store'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { ConfigSuccessResponse, ErrorResponse } from '../cache/response.models'; import { CoreState } from '../core.reducers'; import { PaginatedList } from '../data/paginated-list'; +import { ConfigRequest } from '../data/request.models'; import { PageInfo } from '../shared/page-info.model'; -import { NormalizedSubmissionSectionModel } from './models/normalized-config-submission-section.model'; -import { NormalizedSubmissionDefinitionModel } from './models/normalized-config-submission-definition.model'; +import { ConfigResponseParsingService } from './config-response-parsing.service'; +import { SubmissionDefinitionModel } from './models/config-submission-definition.model'; +import { SubmissionSectionModel } from './models/config-submission-section.model'; describe('ConfigResponseParsingService', () => { let service: ConfigResponseParsingService; const EnvConfig = {} as GlobalConfig; const store = {} as Store; - const objectCacheService = new ObjectCacheService(store); + const objectCacheService = new ObjectCacheService(store, undefined); let validResponse; beforeEach(() => { service = new ConfigResponseParsingService(EnvConfig, objectCacheService); @@ -150,7 +149,7 @@ describe('ConfigResponseParsingService', () => { }, _embedded: [{}, {}], _links: { - self: 'https://rest.api/config/submissiondefinitions/traditional/sections' + self: { href: 'https://rest.api/config/submissiondefinitions/traditional/sections' } } } } @@ -170,77 +169,76 @@ describe('ConfigResponseParsingService', () => { totalElements: 4, totalPages: 1, currentPage: 1, - self: 'https://rest.api/config/submissiondefinitions/traditional/sections' + _links: { + self: { + href: 'https://rest.api/config/submissiondefinitions/traditional/sections' + }, + }, }); const definitions = - Object.assign(new NormalizedSubmissionDefinitionModel(), { + Object.assign(new SubmissionDefinitionModel(), { isDefault: true, name: 'traditional', type: 'submissiondefinition', _links: { - sections: 'https://rest.api/config/submissiondefinitions/traditional/sections', - self: 'https://rest.api/config/submissiondefinitions/traditional' + sections: { href: 'https://rest.api/config/submissiondefinitions/traditional/sections' }, + self: { href: 'https://rest.api/config/submissiondefinitions/traditional' } }, - self: 'https://rest.api/config/submissiondefinitions/traditional', sections: new PaginatedList(pageinfo, [ - Object.assign(new NormalizedSubmissionSectionModel(), { + Object.assign(new SubmissionSectionModel(), { header: 'submit.progressbar.describe.stepone', mandatory: true, sectionType: 'submission-form', - visibility:{ - main:null, - other:'READONLY' + visibility: { + main: null, + other: 'READONLY' }, type: 'submissionsection', _links: { - self: 'https://rest.api/config/submissionsections/traditionalpageone', - config: 'https://rest.api/config/submissionforms/traditionalpageone' + self: { href: 'https://rest.api/config/submissionsections/traditionalpageone' }, + config: { href: 'https://rest.api/config/submissionforms/traditionalpageone' } }, - self: 'https://rest.api/config/submissionsections/traditionalpageone', }), - Object.assign(new NormalizedSubmissionSectionModel(), { + Object.assign(new SubmissionSectionModel(), { header: 'submit.progressbar.describe.steptwo', mandatory: true, sectionType: 'submission-form', - visibility:{ - main:null, - other:'READONLY' + visibility: { + main: null, + other: 'READONLY' }, type: 'submissionsection', _links: { - self: 'https://rest.api/config/submissionsections/traditionalpagetwo', - config: 'https://rest.api/config/submissionforms/traditionalpagetwo' + self: { href: 'https://rest.api/config/submissionsections/traditionalpagetwo' }, + config: { href: 'https://rest.api/config/submissionforms/traditionalpagetwo' } }, - self: 'https://rest.api/config/submissionsections/traditionalpagetwo', }), - Object.assign(new NormalizedSubmissionSectionModel(), { + Object.assign(new SubmissionSectionModel(), { header: 'submit.progressbar.upload', mandatory: false, sectionType: 'upload', - visibility:{ - main:null, - other:'READONLY' + visibility: { + main: null, + other: 'READONLY' }, type: 'submissionsection', _links: { - self: 'https://rest.api/config/submissionsections/upload', - config: 'https://rest.api/config/submissionuploads/upload' + self: { href: 'https://rest.api/config/submissionsections/upload' }, + config: { href: 'https://rest.api/config/submissionuploads/upload' } }, - self: 'https://rest.api/config/submissionsections/upload', }), - Object.assign(new NormalizedSubmissionSectionModel(), { + Object.assign(new SubmissionSectionModel(), { header: 'submit.progressbar.license', mandatory: true, sectionType: 'license', - visibility:{ - main:null, - other:'READONLY' + visibility: { + main: null, + other: 'READONLY' }, type: 'submissionsection', _links: { - self: 'https://rest.api/config/submissionsections/license' + self: { href: 'https://rest.api/config/submissionsections/license' } }, - self: 'https://rest.api/config/submissionsections/license', }) ]) }); diff --git a/src/app/core/config/config-response-parsing.service.ts b/src/app/core/config/config-response-parsing.service.ts index d1f49710d3..d674445d54 100644 --- a/src/app/core/config/config-response-parsing.service.ts +++ b/src/app/core/config/config-response-parsing.service.ts @@ -15,6 +15,7 @@ import { ObjectCacheService } from '../cache/object-cache.service'; @Injectable() export class ConfigResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { protected toCache = false; + protected shouldDirectlyAttachEmbeds = true; constructor( @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, diff --git a/src/app/core/config/models/config-submission-definition.model.ts b/src/app/core/config/models/config-submission-definition.model.ts index 0449e6a964..f3e888d513 100644 --- a/src/app/core/config/models/config-submission-definition.model.ts +++ b/src/app/core/config/models/config-submission-definition.model.ts @@ -1,22 +1,40 @@ -import { ConfigObject } from './config.model'; -import { SubmissionSectionModel } from './config-submission-section.model'; +import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; +import { typedObject } from '../../cache/builders/build-decorators'; import { PaginatedList } from '../../data/paginated-list'; +import { HALLink } from '../../shared/hal-link.model'; import { ResourceType } from '../../shared/resource-type'; +import { SubmissionSectionModel } from './config-submission-section.model'; +import { ConfigObject } from './config.model'; /** * Class for the configuration describing the submission */ +@typedObject +@inheritSerialization(ConfigObject) export class SubmissionDefinitionModel extends ConfigObject { static type = new ResourceType('submissiondefinition'); /** * A boolean representing if this submission definition is the default or not */ + @autoserialize isDefault: boolean; /** * A list of SubmissionSectionModel that are present in this submission definition */ + // TODO refactor using remotedata + @deserialize sections: PaginatedList; + /** + * The links to all related resources returned by the rest api. + */ + @deserialize + _links: { + self: HALLink, + collections: HALLink, + sections: HALLink + }; + } diff --git a/src/app/core/config/models/config-submission-definitions.model.ts b/src/app/core/config/models/config-submission-definitions.model.ts index d9892f542f..1fdf571806 100644 --- a/src/app/core/config/models/config-submission-definitions.model.ts +++ b/src/app/core/config/models/config-submission-definitions.model.ts @@ -1,6 +1,10 @@ +import { inheritSerialization } from 'cerialize'; +import { typedObject } from '../../cache/builders/build-decorators'; import { SubmissionDefinitionModel } from './config-submission-definition.model'; import { ResourceType } from '../../shared/resource-type'; +@typedObject +@inheritSerialization(SubmissionDefinitionModel) export class SubmissionDefinitionsModel extends SubmissionDefinitionModel { static type = new ResourceType('submissiondefinitions'); diff --git a/src/app/core/config/models/config-submission-form.model.ts b/src/app/core/config/models/config-submission-form.model.ts index a65d285c95..d3fcfa9738 100644 --- a/src/app/core/config/models/config-submission-form.model.ts +++ b/src/app/core/config/models/config-submission-form.model.ts @@ -1,3 +1,5 @@ +import { autoserialize, inheritSerialization } from 'cerialize'; +import { typedObject } from '../../cache/builders/build-decorators'; import { ConfigObject } from './config.model'; import { FormFieldModel } from '../../../shared/form/builder/models/form-field.model'; import { ResourceType } from '../../shared/resource-type'; @@ -12,11 +14,14 @@ export interface FormRowModel { /** * A model class for a NormalizedObject. */ +@typedObject +@inheritSerialization(ConfigObject) export class SubmissionFormModel extends ConfigObject { static type = new ResourceType('submissionform'); /** * An array of [FormRowModel] that are present in this form */ + @autoserialize rows: FormRowModel[]; } diff --git a/src/app/core/config/models/config-submission-forms.model.ts b/src/app/core/config/models/config-submission-forms.model.ts index 017d7d68cc..8130bf3264 100644 --- a/src/app/core/config/models/config-submission-forms.model.ts +++ b/src/app/core/config/models/config-submission-forms.model.ts @@ -1,9 +1,13 @@ +import { inheritSerialization } from 'cerialize'; +import { typedObject } from '../../cache/builders/build-decorators'; import { SubmissionFormModel } from './config-submission-form.model'; import { ResourceType } from '../../shared/resource-type'; /** * A model class for a NormalizedObject. */ +@typedObject +@inheritSerialization(SubmissionFormModel) export class SubmissionFormsModel extends SubmissionFormModel { static type = new ResourceType('submissionforms'); } diff --git a/src/app/core/config/models/config-submission-section.model.ts b/src/app/core/config/models/config-submission-section.model.ts index 4c560fa631..d8249297b1 100644 --- a/src/app/core/config/models/config-submission-section.model.ts +++ b/src/app/core/config/models/config-submission-section.model.ts @@ -1,6 +1,9 @@ -import { ConfigObject } from './config.model'; +import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; import { SectionsType } from '../../../submission/sections/sections-type'; +import { typedObject } from '../../cache/builders/build-decorators'; +import { HALLink } from '../../shared/hal-link.model'; import { ResourceType } from '../../shared/resource-type'; +import { ConfigObject } from './config.model'; /** * An interface that define section visibility and its properties. @@ -10,27 +13,42 @@ export interface SubmissionSectionVisibility { other: any } +@typedObject +@inheritSerialization(ConfigObject) export class SubmissionSectionModel extends ConfigObject { static type = new ResourceType('submissionsection'); /** * The header for this section */ + @autoserialize header: string; /** * A boolean representing if this submission section is the mandatory or not */ + @autoserialize mandatory: boolean; /** * A string representing the kind of section object */ + @autoserialize sectionType: SectionsType; /** * The [SubmissionSectionVisibility] object for this section */ - visibility: SubmissionSectionVisibility + @autoserialize + visibility: SubmissionSectionVisibility; + + /** + * The {@link HALLink}s for this SubmissionSectionModel + */ + @deserialize + _links: { + self: HALLink; + config: HALLink; + } } diff --git a/src/app/core/config/models/config-submission-sections.model.ts b/src/app/core/config/models/config-submission-sections.model.ts index ae7b133391..7f78712273 100644 --- a/src/app/core/config/models/config-submission-sections.model.ts +++ b/src/app/core/config/models/config-submission-sections.model.ts @@ -1,6 +1,10 @@ +import { inheritSerialization } from 'cerialize'; +import { typedObject } from '../../cache/builders/build-decorators'; import { SubmissionSectionModel } from './config-submission-section.model'; import { ResourceType } from '../../shared/resource-type'; +@typedObject +@inheritSerialization(SubmissionSectionModel) export class SubmissionSectionsModel extends SubmissionSectionModel { static type = new ResourceType('submissionsections'); } diff --git a/src/app/core/config/models/config-submission-uploads.model.ts b/src/app/core/config/models/config-submission-uploads.model.ts index 812a590041..b7733ee25d 100644 --- a/src/app/core/config/models/config-submission-uploads.model.ts +++ b/src/app/core/config/models/config-submission-uploads.model.ts @@ -1,22 +1,30 @@ +import { autoserialize, inheritSerialization } from 'cerialize'; +import { typedObject } from '../../cache/builders/build-decorators'; import { ConfigObject } from './config.model'; import { AccessConditionOption } from './config-access-condition-option.model'; import { SubmissionFormsModel } from './config-submission-forms.model'; import { ResourceType } from '../../shared/resource-type'; +@typedObject +@inheritSerialization(ConfigObject) export class SubmissionUploadsModel extends ConfigObject { static type = new ResourceType('submissionupload'); /** * A list of available bitstream access conditions */ + @autoserialize accessConditionOptions: AccessConditionOption[]; /** * An object representing the configuration describing the bistream metadata form */ + @autoserialize metadata: SubmissionFormsModel; + @autoserialize required: boolean; + @autoserialize maxSize: number; } diff --git a/src/app/core/config/models/config.model.ts b/src/app/core/config/models/config.model.ts index 20d67ec69d..fabb16eb23 100644 --- a/src/app/core/config/models/config.model.ts +++ b/src/app/core/config/models/config.model.ts @@ -1,22 +1,30 @@ +import { autoserialize, deserialize } from 'cerialize'; import { CacheableObject } from '../../cache/object-cache.reducer'; +import { HALLink } from '../../shared/hal-link.model'; import { ResourceType } from '../../shared/resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; export abstract class ConfigObject implements CacheableObject { /** * The name for this configuration */ + @autoserialize public name: string; + /** + * The type of this ConfigObject + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + /** * The links to all related resources returned by the rest api. */ - public _links: { - [name: string]: string + @deserialize + _links: { + self: HALLink, + [name: string]: HALLink }; - - /** - * The link to the rest endpoint where this config object can be found - */ - self: string; } diff --git a/src/app/core/config/models/normalized-config-submission-definition.model.ts b/src/app/core/config/models/normalized-config-submission-definition.model.ts deleted file mode 100644 index cb56e01acf..0000000000 --- a/src/app/core/config/models/normalized-config-submission-definition.model.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; -import { SubmissionSectionModel } from './config-submission-section.model'; -import { PaginatedList } from '../../data/paginated-list'; -import { NormalizedConfigObject } from './normalized-config.model'; -import { SubmissionDefinitionsModel } from './config-submission-definitions.model'; -import { mapsTo } from '../../cache/builders/build-decorators'; -import { SubmissionDefinitionModel } from './config-submission-definition.model'; - -/** - * Normalized class for the configuration describing the submission - */ -@mapsTo(SubmissionDefinitionModel) -@inheritSerialization(NormalizedConfigObject) -export class NormalizedSubmissionDefinitionModel extends NormalizedConfigObject { - - /** - * A boolean representing if this submission definition is the default or not - */ - @autoserialize - isDefault: boolean; - - /** - * A list of SubmissionSectionModel that are present in this submission definition - */ - @autoserializeAs(SubmissionSectionModel) - sections: PaginatedList; - -} diff --git a/src/app/core/config/models/normalized-config-submission-definitions.model.ts b/src/app/core/config/models/normalized-config-submission-definitions.model.ts deleted file mode 100644 index 4c52d96458..0000000000 --- a/src/app/core/config/models/normalized-config-submission-definitions.model.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { inheritSerialization } from 'cerialize'; -import { NormalizedConfigObject } from './normalized-config.model'; -import { SubmissionDefinitionsModel } from './config-submission-definitions.model'; -import { mapsTo } from '../../cache/builders/build-decorators'; -import { NormalizedSubmissionDefinitionModel } from './normalized-config-submission-definition.model'; - -/** - * Normalized class for the configuration describing the submission - */ -@mapsTo(SubmissionDefinitionsModel) -@inheritSerialization(NormalizedConfigObject) -export class NormalizedSubmissionDefinitionsModel extends NormalizedSubmissionDefinitionModel { -} diff --git a/src/app/core/config/models/normalized-config-submission-form.model.ts b/src/app/core/config/models/normalized-config-submission-form.model.ts deleted file mode 100644 index afdfef4818..0000000000 --- a/src/app/core/config/models/normalized-config-submission-form.model.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; -import { NormalizedConfigObject } from './normalized-config.model'; -import { mapsTo } from '../../cache/builders/build-decorators'; -import { FormRowModel, SubmissionFormModel } from './config-submission-form.model'; - -/** - * Normalized class for the configuration describing the submission form - */ -@mapsTo(SubmissionFormModel) -@inheritSerialization(NormalizedConfigObject) -export class NormalizedSubmissionFormModel extends NormalizedConfigObject { - - /** - * An array of [FormRowModel] that are present in this form - */ - @autoserialize - rows: FormRowModel[]; -} diff --git a/src/app/core/config/models/normalized-config-submission-forms.model.ts b/src/app/core/config/models/normalized-config-submission-forms.model.ts deleted file mode 100644 index c040a94587..0000000000 --- a/src/app/core/config/models/normalized-config-submission-forms.model.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { inheritSerialization } from 'cerialize'; -import { mapsTo } from '../../cache/builders/build-decorators'; -import { SubmissionFormsModel } from './config-submission-forms.model'; -import { NormalizedSubmissionFormModel } from './normalized-config-submission-form.model'; - -/** - * Normalized class for the configuration describing the submission form - */ -@mapsTo(SubmissionFormsModel) -@inheritSerialization(NormalizedSubmissionFormModel) -export class NormalizedSubmissionFormsModel extends NormalizedSubmissionFormModel { -} diff --git a/src/app/core/config/models/normalized-config-submission-section.model.ts b/src/app/core/config/models/normalized-config-submission-section.model.ts deleted file mode 100644 index 364a981060..0000000000 --- a/src/app/core/config/models/normalized-config-submission-section.model.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; -import { SectionsType } from '../../../submission/sections/sections-type'; -import { NormalizedConfigObject } from './normalized-config.model'; -import { - SubmissionSectionModel, - SubmissionSectionVisibility -} from './config-submission-section.model'; -import { mapsTo } from '../../cache/builders/build-decorators'; - -/** - * Normalized class for the configuration describing the submission section - */ -@mapsTo(SubmissionSectionModel) -@inheritSerialization(NormalizedConfigObject) -export class NormalizedSubmissionSectionModel extends NormalizedConfigObject { - - /** - * The header for this section - */ - @autoserialize - header: string; - - /** - * A boolean representing if this submission section is the mandatory or not - */ - @autoserialize - mandatory: boolean; - - /** - * A string representing the kind of section object - */ - @autoserialize - sectionType: SectionsType; - - /** - * The [SubmissionSectionVisibility] object for this section - */ - @autoserialize - visibility: SubmissionSectionVisibility - -} diff --git a/src/app/core/config/models/normalized-config-submission-sections.model.ts b/src/app/core/config/models/normalized-config-submission-sections.model.ts deleted file mode 100644 index fb1e4c671a..0000000000 --- a/src/app/core/config/models/normalized-config-submission-sections.model.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; -import { SectionsType } from '../../../submission/sections/sections-type'; -import { NormalizedConfigObject } from './normalized-config.model'; -import { - SubmissionSectionModel, - SubmissionSectionVisibility -} from './config-submission-section.model'; -import { mapsTo } from '../../cache/builders/build-decorators'; -import { SubmissionSectionsModel } from './config-submission-sections.model'; -import { NormalizedSubmissionSectionModel } from './normalized-config-submission-section.model'; - -/** - * Normalized class for the configuration describing the submission section - */ -@mapsTo(SubmissionSectionsModel) -@inheritSerialization(NormalizedSubmissionSectionModel) -export class NormalizedSubmissionSectionsModel extends NormalizedSubmissionSectionModel { -} diff --git a/src/app/core/config/models/normalized-config-submission-uploads.model.ts b/src/app/core/config/models/normalized-config-submission-uploads.model.ts deleted file mode 100644 index 7a21c15912..0000000000 --- a/src/app/core/config/models/normalized-config-submission-uploads.model.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; -import { AccessConditionOption } from './config-access-condition-option.model'; -import { SubmissionFormsModel } from './config-submission-forms.model'; -import { NormalizedConfigObject } from './normalized-config.model'; -import { SubmissionUploadsModel } from './config-submission-uploads.model'; -import { mapsTo } from '../../cache/builders/build-decorators'; - -/** - * Normalized class for the configuration describing the submission upload section - */ -@mapsTo(SubmissionUploadsModel) -@inheritSerialization(NormalizedConfigObject) -export class NormalizedSubmissionUploadsModel extends NormalizedConfigObject { - - /** - * A list of available bitstream access conditions - */ - @autoserialize - accessConditionOptions: AccessConditionOption[]; - - /** - * An object representing the configuration describing the bistream metadata form - */ - @autoserializeAs(SubmissionFormsModel) - metadata: SubmissionFormsModel; - - @autoserialize - required: boolean; - - @autoserialize - maxSize: number; - -} diff --git a/src/app/core/config/models/normalized-config.model.ts b/src/app/core/config/models/normalized-config.model.ts deleted file mode 100644 index 1bf4ffb826..0000000000 --- a/src/app/core/config/models/normalized-config.model.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; -import { NormalizedObject } from '../../cache/models/normalized-object.model'; -import { CacheableObject, TypedObject } from '../../cache/object-cache.reducer'; -import { ResourceType } from '../../shared/resource-type'; - -/** - * Normalized abstract class for a configuration object - */ -@inheritSerialization(NormalizedObject) -export abstract class NormalizedConfigObject implements CacheableObject { - - /** - * The name for this configuration - */ - @autoserialize - public name: string; - - /** - * The links to all related resources returned by the rest api. - */ - @autoserialize - public _links: { - [name: string]: string - }; - - /** - * The link to the rest endpoint where this config object can be found - */ - @autoserialize - self: string; - -} diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 57034a9737..56062a0c41 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -1,147 +1,150 @@ -import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core'; import { CommonModule } from '@angular/common'; - -import { StoreModule } from '@ngrx/store'; -import { EffectsModule } from '@ngrx/effects'; -import { - DynamicFormLayoutService, - DynamicFormService, - DynamicFormValidationService -} from '@ng-dynamic-forms/core'; - -import { coreEffects } from './core.effects'; -import { coreReducers } from './core.reducers'; - -import { isNotEmpty } from '../shared/empty.util'; - -import { ApiService } from './services/api.service'; -import { BrowseEntriesResponseParsingService } from './data/browse-entries-response-parsing.service'; -import { CollectionDataService } from './data/collection-data.service'; -import { CommunityDataService } from './data/community-data.service'; -import { DebugResponseParsingService } from './data/debug-response-parsing.service'; -import { DSOResponseParsingService } from './data/dso-response-parsing.service'; -import { SearchResponseParsingService } from './data/search-response-parsing.service'; -import { DSpaceRESTv2Service } from './dspace-rest-v2/dspace-rest-v2.service'; -import { FormBuilderService } from '../shared/form/builder/form-builder.service'; -import { SectionFormOperationsService } from '../submission/sections/form/section-form-operations.service'; -import { FormService } from '../shared/form/form.service'; -import { GroupEpersonService } from './eperson/group-eperson.service'; -import { HostWindowService } from '../shared/host-window.service'; -import { ItemDataService } from './data/item-data.service'; -import { MetadataService } from './metadata/metadata.service'; -import { ObjectCacheService } from './cache/object-cache.service'; -import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; -import { RemoteDataBuildService } from './cache/builders/remote-data-build.service'; -import { RequestService } from './data/request.service'; -import { EndpointMapResponseParsingService } from './data/endpoint-map-response-parsing.service'; -import { ServerResponseService } from './services/server-response.service'; -import { NativeWindowFactory, NativeWindowService } from './services/window.service'; -import { BrowseService } from './browse/browse.service'; -import { BrowseResponseParsingService } from './data/browse-response-parsing.service'; -import { ConfigResponseParsingService } from './config/config-response-parsing.service'; -import { RouteService } from './services/route.service'; -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 { SubmissionResponseParsingService } from './submission/submission-response-parsing.service'; -import { EpersonResponseParsingService } from './eperson/eperson-response-parsing.service'; -import { JsonPatchOperationsBuilder } from './json-patch/builder/json-patch-operations-builder'; -import { AuthorityService } from './integration/authority.service'; -import { IntegrationResponseParsingService } from './integration/integration-response-parsing.service'; -import { WorkspaceitemDataService } from './submission/workspaceitem-data.service'; -import { UUIDService } from './shared/uuid.service'; -import { AuthenticatedGuard } from './auth/authenticated.guard'; -import { AuthRequestService } from './auth/auth-request.service'; -import { AuthResponseParsingService } from './auth/auth-response-parsing.service'; import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http'; -import { AuthInterceptor } from './auth/auth.interceptor'; -import { HALEndpointService } from './shared/hal-endpoint.service'; -import { FacetValueResponseParsingService } from './data/facet-value-response-parsing.service'; -import { FacetValueMapResponseParsingService } from './data/facet-value-map-response-parsing.service'; -import { FacetConfigResponseParsingService } from './data/facet-config-response-parsing.service'; -import { ResourcePolicyService } from './data/resource-policy.service'; -import { RegistryService } from './registry/registry.service'; -import { RegistryMetadataschemasResponseParsingService } from './data/registry-metadataschemas-response-parsing.service'; -import { RegistryMetadatafieldsResponseParsingService } from './data/registry-metadatafields-response-parsing.service'; -import { RegistryBitstreamformatsResponseParsingService } from './data/registry-bitstreamformats-response-parsing.service'; -import { WorkflowItemDataService } from './submission/workflowitem-data.service'; -import { NotificationsService } from '../shared/notifications/notifications.service'; -import { UploaderService } from '../shared/uploader/uploader.service'; -import { FileService } from './shared/file.service'; -import { SubmissionRestService } from './submission/submission-rest.service'; -import { BrowseItemsResponseParsingService } from './data/browse-items-response-parsing-service'; -import { DSpaceObjectDataService } from './data/dspace-object-data.service'; -import { MetadataschemaParsingService } from './data/metadataschema-parsing.service'; -import { FilteredDiscoveryPageResponseParsingService } from './data/filtered-discovery-page-response-parsing.service'; -import { CSSVariableService } from '../shared/sass-helper/sass-helper.service'; -import { MenuService } from '../shared/menu/menu.service'; -import { SubmissionJsonPatchOperationsService } from './submission/submission-json-patch-operations.service'; -import { NormalizedObjectBuildService } from './cache/builders/normalized-object-build.service'; -import { DSOChangeAnalyzer } from './data/dso-change-analyzer.service'; -import { ObjectUpdatesService } from './data/object-updates/object-updates.service'; -import { DefaultChangeAnalyzer } from './data/default-change-analyzer.service'; -import { SearchService } from './shared/search/search.service'; -import { RelationshipService } from './data/relationship.service'; -import { NormalizedCollection } from './cache/models/normalized-collection.model'; -import { NormalizedCommunity } from './cache/models/normalized-community.model'; -import { NormalizedDSpaceObject } from './cache/models/normalized-dspace-object.model'; -import { NormalizedBitstream } from './cache/models/normalized-bitstream.model'; -import { NormalizedBundle } from './cache/models/normalized-bundle.model'; -import { NormalizedBitstreamFormat } from './cache/models/normalized-bitstream-format.model'; -import { NormalizedItem } from './cache/models/normalized-item.model'; -import { NormalizedEPerson } from './eperson/models/normalized-eperson.model'; -import { NormalizedGroup } from './eperson/models/normalized-group.model'; -import { NormalizedResourcePolicy } from './cache/models/normalized-resource-policy.model'; -import { NormalizedMetadataSchema } from './metadata/normalized-metadata-schema.model'; -import { NormalizedMetadataField } from './metadata/normalized-metadata-field.model'; -import { NormalizedLicense } from './cache/models/normalized-license.model'; -import { NormalizedWorkflowItem } from './submission/models/normalized-workflowitem.model'; -import { NormalizedWorkspaceItem } from './submission/models/normalized-workspaceitem.model'; -import { NormalizedSubmissionDefinitionsModel } from './config/models/normalized-config-submission-definitions.model'; -import { NormalizedSubmissionFormsModel } from './config/models/normalized-config-submission-forms.model'; -import { NormalizedSubmissionSectionModel } from './config/models/normalized-config-submission-section.model'; -import { NormalizedAuthStatus } from './auth/models/normalized-auth-status.model'; -import { NormalizedAuthorityValue } from './integration/models/normalized-authority-value.model'; -import { RoleService } from './roles/role.service'; -import { MyDSpaceGuard } from '../+my-dspace-page/my-dspace.guard'; -import { MyDSpaceResponseParsingService } from './data/mydspace-response-parsing.service'; -import { ClaimedTaskDataService } from './tasks/claimed-task-data.service'; -import { PoolTaskDataService } from './tasks/pool-task-data.service'; -import { TaskResponseParsingService } from './tasks/task-response-parsing.service'; -import { BitstreamFormatDataService } from './data/bitstream-format-data.service'; -import { NormalizedClaimedTask } from './tasks/models/normalized-claimed-task-object.model'; -import { NormalizedTaskObject } from './tasks/models/normalized-task-object.model'; -import { NormalizedPoolTask } from './tasks/models/normalized-pool-task-object.model'; -import { NormalizedRelationship } from './cache/models/items/normalized-relationship.model'; -import { NormalizedRelationshipType } from './cache/models/items/normalized-relationship-type.model'; -import { NormalizedItemType } from './cache/models/items/normalized-item-type.model'; -import { MetadatafieldParsingService } from './data/metadatafield-parsing.service'; -import { NormalizedSubmissionUploadsModel } from './config/models/normalized-config-submission-uploads.model'; -import { NormalizedBrowseEntry } from './shared/normalized-browse-entry.model'; -import { BrowseDefinition } from './shared/browse-definition.model'; -import { ItemTemplateDataService } from './data/item-template-data.service'; -import { MappedCollectionsReponseParsingService } from './data/mapped-collections-reponse-parsing.service'; -import { ObjectSelectService } from '../shared/object-select/object-select.service'; -import { SiteDataService } from './data/site-data.service'; -import { NormalizedSite } from './cache/models/normalized-site.model'; +import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core'; +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, mockResponseMap } from '../shared/mocks/dspace-rest-v2/mocks/mock-response-map'; -import { EndpointMockingRestService } from '../shared/mocks/dspace-rest-v2/endpoint-mocking-rest.service'; -import { ENV_CONFIG, GLOBAL_CONFIG, GlobalConfig } from '../../config'; -import { SearchFilterService } from './shared/search/search-filter.service'; -import { SearchConfigurationService } from './shared/search/search-configuration.service'; +import { NotificationsService } from '../shared/notifications/notifications.service'; import { SelectableListService } from '../shared/object-list/selectable-list/selectable-list.service'; -import { RelationshipTypeService } from './data/relationship-type.service'; +import { ObjectSelectService } from '../shared/object-select/object-select.service'; +import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; +import { CSSVariableService } from '../shared/sass-helper/sass-helper.service'; import { SidebarService } from '../shared/sidebar/sidebar.service'; -import { NormalizedExternalSource } from './cache/models/normalized-external-source.model'; -import { NormalizedExternalSourceEntry } from './cache/models/normalized-external-source-entry.model'; +import { UploaderService } from '../shared/uploader/uploader.service'; +import { SectionFormOperationsService } from '../submission/sections/form/section-form-operations.service'; +import { AuthRequestService } from './auth/auth-request.service'; +import { AuthResponseParsingService } from './auth/auth-response-parsing.service'; +import { AuthInterceptor } from './auth/auth.interceptor'; +import { AuthenticatedGuard } from './auth/authenticated.guard'; +import { AuthStatus } from './auth/models/auth-status.model'; +import { BrowseService } from './browse/browse.service'; +import { RemoteDataBuildService } from './cache/builders/remote-data-build.service'; +import { ObjectCacheService } from './cache/object-cache.service'; +import { ConfigResponseParsingService } from './config/config-response-parsing.service'; +import { SubmissionDefinitionsModel } from './config/models/config-submission-definitions.model'; +import { SubmissionFormsModel } from './config/models/config-submission-forms.model'; +import { SubmissionSectionModel } from './config/models/config-submission-section.model'; +import { SubmissionUploadsModel } from './config/models/config-submission-uploads.model'; +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'; +import { BrowseEntriesResponseParsingService } from './data/browse-entries-response-parsing.service'; +import { BrowseItemsResponseParsingService } from './data/browse-items-response-parsing-service'; +import { BrowseResponseParsingService } from './data/browse-response-parsing.service'; +import { CollectionDataService } from './data/collection-data.service'; +import { CommunityDataService } from './data/community-data.service'; +import { ContentSourceResponseParsingService } from './data/content-source-response-parsing.service'; +import { DebugResponseParsingService } from './data/debug-response-parsing.service'; +import { DefaultChangeAnalyzer } from './data/default-change-analyzer.service'; +import { DSOChangeAnalyzer } from './data/dso-change-analyzer.service'; +import { DSOResponseParsingService } from './data/dso-response-parsing.service'; +import { DSpaceObjectDataService } from './data/dspace-object-data.service'; +import { EndpointMapResponseParsingService } from './data/endpoint-map-response-parsing.service'; +import { ItemTypeDataService } from './data/entity-type-data.service'; +import { EntityTypeService } from './data/entity-type.service'; import { ExternalSourceService } from './data/external-source.service'; +import { FacetConfigResponseParsingService } from './data/facet-config-response-parsing.service'; +import { FacetValueMapResponseParsingService } from './data/facet-value-map-response-parsing.service'; +import { FacetValueResponseParsingService } from './data/facet-value-response-parsing.service'; +import { FilteredDiscoveryPageResponseParsingService } from './data/filtered-discovery-page-response-parsing.service'; +import { ItemDataService } from './data/item-data.service'; +import { LicenseDataService } from './data/license-data.service'; import { LookupRelationService } from './data/lookup-relation.service'; -import { NormalizedTemplateItem } from './cache/models/normalized-template-item.model'; +import { MappedCollectionsReponseParsingService } from './data/mapped-collections-reponse-parsing.service'; +import { MetadatafieldParsingService } from './data/metadatafield-parsing.service'; +import { MetadataschemaParsingService } from './data/metadataschema-parsing.service'; +import { MyDSpaceResponseParsingService } from './data/mydspace-response-parsing.service'; +import { ObjectUpdatesService } from './data/object-updates/object-updates.service'; +import { RegistryBitstreamformatsResponseParsingService } from './data/registry-bitstreamformats-response-parsing.service'; +import { RegistryMetadatafieldsResponseParsingService } from './data/registry-metadatafields-response-parsing.service'; +import { RegistryMetadataschemasResponseParsingService } from './data/registry-metadataschemas-response-parsing.service'; +import { RelationshipTypeService } from './data/relationship-type.service'; +import { RelationshipService } from './data/relationship.service'; +import { ResourcePolicyService } from './data/resource-policy.service'; +import { SearchResponseParsingService } from './data/search-response-parsing.service'; +import { SiteDataService } from './data/site-data.service'; +import { DSpaceRESTv2Service } from './dspace-rest-v2/dspace-rest-v2.service'; +import { EPersonDataService } from './eperson/eperson-data.service'; +import { EpersonResponseParsingService } from './eperson/eperson-response-parsing.service'; +import { EPerson } from './eperson/models/eperson.model'; +import { Group } from './eperson/models/group.model'; +import { AuthorityService } from './integration/authority.service'; +import { IntegrationResponseParsingService } from './integration/integration-response-parsing.service'; +import { AuthorityValue } from './integration/models/authority.value'; +import { JsonPatchOperationsBuilder } from './json-patch/builder/json-patch-operations-builder'; +import { MetadataField } from './metadata/metadata-field.model'; +import { MetadataSchema } from './metadata/metadata-schema.model'; +import { MetadataService } from './metadata/metadata.service'; +import { RegistryService } from './registry/registry.service'; +import { RoleService } from './roles/role.service'; + +import { ApiService } from './services/api.service'; +import { ServerResponseService } from './services/server-response.service'; +import { NativeWindowFactory, NativeWindowService } from './services/window.service'; +import { BitstreamFormat } from './shared/bitstream-format.model'; +import { Bitstream } from './shared/bitstream.model'; +import { BrowseDefinition } from './shared/browse-definition.model'; +import { BrowseEntry } from './shared/browse-entry.model'; +import { Bundle } from './shared/bundle.model'; +import { Collection } from './shared/collection.model'; +import { Community } from './shared/community.model'; +import { DSpaceObject } from './shared/dspace-object.model'; +import { ExternalSourceEntry } from './shared/external-source-entry.model'; +import { ExternalSource } from './shared/external-source.model'; +import { FileService } from './shared/file.service'; +import { HALEndpointService } from './shared/hal-endpoint.service'; +import { ItemType } from './shared/item-relationships/item-type.model'; +import { RelationshipType } from './shared/item-relationships/relationship-type.model'; +import { Relationship } from './shared/item-relationships/relationship.model'; +import { Item } from './shared/item.model'; +import { License } from './shared/license.model'; +import { ResourcePolicy } from './shared/resource-policy.model'; +import { SearchConfigurationService } from './shared/search/search-configuration.service'; +import { SearchFilterService } from './shared/search/search-filter.service'; +import { SearchService } from './shared/search/search.service'; +import { Site } from './shared/site.model'; +import { UUIDService } from './shared/uuid.service'; +import { WorkflowItem } from './submission/models/workflowitem.model'; +import { WorkspaceItem } from './submission/models/workspaceitem.model'; +import { SubmissionJsonPatchOperationsService } from './submission/submission-json-patch-operations.service'; +import { SubmissionResponseParsingService } from './submission/submission-response-parsing.service'; +import { SubmissionRestService } from './submission/submission-rest.service'; +import { WorkflowItemDataService } from './submission/workflowitem-data.service'; +import { WorkspaceitemDataService } from './submission/workspaceitem-data.service'; +import { ClaimedTaskDataService } from './tasks/claimed-task-data.service'; +import { ClaimedTask } from './tasks/models/claimed-task-object.model'; +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 { ArrayMoveChangeAnalyzer } from './data/array-move-change-analyzer.service'; +import { BitstreamDataService } from './data/bitstream-data.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'; +import { WorkflowActionDataService } from './data/workflow-action-data.service'; +import { WorkflowAction } from './tasks/models/workflow-action-object.model'; +import { ItemTemplateDataService } from './data/item-template-data.service'; +import { TemplateItem } from './shared/template-item.model'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -175,7 +178,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, @@ -183,7 +190,7 @@ const PROVIDERS = [ SectionFormOperationsService, FormService, EpersonResponseParsingService, - GroupEpersonService, + EPersonDataService, HALEndpointService, HostWindowService, ItemDataService, @@ -193,9 +200,7 @@ const PROVIDERS = [ ResourcePolicyService, RegistryService, BitstreamFormatDataService, - NormalizedObjectBuildService, RemoteDataBuildService, - RequestService, EndpointMapResponseParsingService, FacetValueResponseParsingService, FacetValueMapResponseParsingService, @@ -213,7 +218,6 @@ const PROVIDERS = [ BrowseItemsResponseParsingService, BrowseService, ConfigResponseParsingService, - RouteService, SubmissionDefinitionsConfigService, SubmissionFormsConfigService, SubmissionRestService, @@ -235,6 +239,7 @@ const PROVIDERS = [ DSpaceObjectDataService, DSOChangeAnalyzer, DefaultChangeAnalyzer, + ArrayMoveChangeAnalyzer, ObjectSelectService, CSSVariableService, MenuService, @@ -246,6 +251,9 @@ const PROVIDERS = [ TaskResponseParsingService, ClaimedTaskDataService, PoolTaskDataService, + BitstreamDataService, + EntityTypeService, + ContentSourceResponseParsingService, ItemTemplateDataService, SearchService, SidebarService, @@ -256,6 +264,11 @@ const PROVIDERS = [ RelationshipTypeService, ExternalSourceService, LookupRelationService, + VersionDataService, + VersionHistoryDataService, + LicenseDataService, + ItemTypeDataService, + WorkflowActionDataService, // register AuthInterceptor as HttpInterceptor { provide: HTTP_INTERCEPTORS, @@ -270,41 +283,44 @@ const PROVIDERS = [ /** * Declaration needed to make sure all decorator functions are called in time */ -export const normalizedModels = +export const models = [ - NormalizedDSpaceObject, - NormalizedBundle, - NormalizedBitstream, - NormalizedBitstreamFormat, - NormalizedItem, - NormalizedSite, - NormalizedCollection, - NormalizedCommunity, - NormalizedEPerson, - NormalizedGroup, - NormalizedResourcePolicy, - NormalizedMetadataSchema, - NormalizedMetadataField, - NormalizedLicense, - NormalizedWorkflowItem, - NormalizedWorkspaceItem, - NormalizedSubmissionDefinitionsModel, - NormalizedSubmissionFormsModel, - NormalizedSubmissionSectionModel, - NormalizedSubmissionUploadsModel, - NormalizedAuthStatus, - NormalizedAuthorityValue, - NormalizedBrowseEntry, + DSpaceObject, + Bundle, + Bitstream, + BitstreamFormat, + Item, + Site, + Collection, + Community, + EPerson, + Group, + ResourcePolicy, + MetadataSchema, + MetadataField, + License, + WorkflowItem, + WorkspaceItem, + SubmissionDefinitionsModel, + SubmissionFormsModel, + SubmissionSectionModel, + SubmissionUploadsModel, + AuthStatus, + AuthorityValue, + BrowseEntry, BrowseDefinition, - NormalizedClaimedTask, - NormalizedTaskObject, - NormalizedPoolTask, - NormalizedRelationship, - NormalizedRelationshipType, - NormalizedItemType, - NormalizedExternalSource, - NormalizedExternalSourceEntry, - NormalizedTemplateItem + ClaimedTask, + TaskObject, + PoolTask, + Relationship, + RelationshipType, + ItemType, + ExternalSource, + ExternalSourceEntry, + Version, + VersionHistory, + WorkflowAction, + TemplateItem ]; @NgModule({ diff --git a/src/app/core/data/array-move-change-analyzer.service.spec.ts b/src/app/core/data/array-move-change-analyzer.service.spec.ts new file mode 100644 index 0000000000..5f5388d935 --- /dev/null +++ b/src/app/core/data/array-move-change-analyzer.service.spec.ts @@ -0,0 +1,107 @@ +import { ArrayMoveChangeAnalyzer } from './array-move-change-analyzer.service'; +import { moveItemInArray } from '@angular/cdk/drag-drop'; +import { Operation } from 'fast-json-patch'; + +/** + * Helper class for creating move tests + * Define a "from" and "to" index to move objects within the array before comparing + */ +class MoveTest { + from: number; + to: number; + + constructor(from: number, to: number) { + this.from = from; + this.to = to; + } +} + +describe('ArrayMoveChangeAnalyzer', () => { + const comparator = new ArrayMoveChangeAnalyzer(); + + let originalArray = []; + + describe('when all values are defined', () => { + beforeEach(() => { + originalArray = [ + '98700118-d65d-4636-b1d0-dba83fc932e1', + '4d7d0798-a8fa-45b8-b4fc-deb2819606c8', + 'e56eb99e-2f7c-4bee-9b3f-d3dcc83386b1', + '0f608168-cdfc-46b0-92ce-889f7d3ac684', + '546f9f5c-15dc-4eec-86fe-648007ac9e1c' + ]; + }); + + testMove([ + { op: 'move', from: '/2', path: '/4' }, + ], new MoveTest(2, 4)); + + testMove([ + { op: 'move', from: '/0', path: '/3' }, + ], new MoveTest(0, 3)); + + testMove([ + { op: 'move', from: '/0', path: '/3' }, + { op: 'move', from: '/2', path: '/1' } + ], new MoveTest(0, 3), new MoveTest(1, 2)); + + testMove([ + { op: 'move', from: '/0', path: '/1' }, + { op: 'move', from: '/3', path: '/4' } + ], new MoveTest(0, 1), new MoveTest(3, 4)); + + testMove([], new MoveTest(0, 4), new MoveTest(4, 0)); + + testMove([ + { op: 'move', from: '/0', path: '/3' }, + { op: 'move', from: '/2', path: '/1' } + ], new MoveTest(0, 4), new MoveTest(1, 3), new MoveTest(2, 4)); + }); + + describe('when some values are undefined (index 2 and 3)', () => { + beforeEach(() => { + originalArray = [ + '98700118-d65d-4636-b1d0-dba83fc932e1', + '4d7d0798-a8fa-45b8-b4fc-deb2819606c8', + undefined, + undefined, + '546f9f5c-15dc-4eec-86fe-648007ac9e1c' + ]; + }); + + // It can't create a move operation for undefined values, so it should create move operations for the defined values instead + testMove([ + { op: 'move', from: '/4', path: '/3' }, + ], new MoveTest(2, 4)); + + // Moving a defined value should result in the same operations + testMove([ + { op: 'move', from: '/0', path: '/3' }, + ], new MoveTest(0, 3)); + }); + + /** + * Helper function for creating a move test + * + * @param expectedOperations An array of expected operations after comparing the original array with the array + * created using the provided MoveTests + * @param moves An array of MoveTest objects telling the test where to move objects before comparing + */ + function testMove(expectedOperations: Operation[], ...moves: MoveTest[]) { + describe(`move ${moves.map((move) => `${move.from} to ${move.to}`).join(' and ')}`, () => { + let result; + + beforeEach(() => { + const movedArray = [...originalArray]; + moves.forEach((move) => { + moveItemInArray(movedArray, move.from, move.to); + }); + result = comparator.diff(originalArray, movedArray); + }); + + it('should create the expected move operations', () => { + expect(result).toEqual(expectedOperations); + }); + }); + } +}); diff --git a/src/app/core/data/array-move-change-analyzer.service.ts b/src/app/core/data/array-move-change-analyzer.service.ts new file mode 100644 index 0000000000..39d22fc463 --- /dev/null +++ b/src/app/core/data/array-move-change-analyzer.service.ts @@ -0,0 +1,37 @@ +import { MoveOperation } from 'fast-json-patch/lib/core'; +import { Injectable } from '@angular/core'; +import { moveItemInArray } from '@angular/cdk/drag-drop'; +import { hasValue } from '../../shared/empty.util'; + +/** + * A class to determine move operations between two arrays + */ +@Injectable() +export class ArrayMoveChangeAnalyzer { + + /** + * Compare two arrays detecting and returning move operations + * + * @param array1 The original array + * @param array2 The custom array to compare with the original + */ + diff(array1: T[], array2: T[]): MoveOperation[] { + const result = []; + const moved = [...array1]; + array1.forEach((value: T, index: number) => { + if (hasValue(value)) { + const otherIndex = array2.indexOf(value); + const movedIndex = moved.indexOf(value); + if (index !== otherIndex && movedIndex !== otherIndex) { + moveItemInArray(moved, movedIndex, otherIndex); + result.push(Object.assign({ + op: 'move', + from: '/' + movedIndex, + path: '/' + otherIndex + }) as MoveOperation) + } + } + }); + return result; + } +} 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 ea2d71faa7..efbe838d82 100644 --- a/src/app/core/data/base-response-parsing.service.ts +++ b/src/app/core/data/base-response-parsing.service.ts @@ -1,21 +1,45 @@ import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util'; -import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; +import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; import { CacheableObject } from '../cache/object-cache.reducer'; +import { Serializer } from '../serializer'; import { PageInfo } from '../shared/page-info.model'; import { ObjectCacheService } from '../cache/object-cache.service'; import { GlobalConfig } from '../../../config/global-config.interface'; import { GenericConstructor } from '../shared/generic-constructor'; import { PaginatedList } from './paginated-list'; -import { isRestDataObject, isRestPaginatedList } from '../cache/builders/normalized-object-build.service'; -import { ResourceType } from '../shared/resource-type'; -import { getMapsToType } from '../cache/builders/build-decorators'; +import { getClassForType } from '../cache/builders/build-decorators'; import { RestRequest } from './request.models'; /* tslint:disable:max-classes-per-file */ +/** + * Return true if halObj has a value for `_links.self` + * + * @param {any} halObj The object to test + */ +export function isRestDataObject(halObj: any): boolean { + return isNotEmpty(halObj._links) && hasValue(halObj._links.self); +} + +/** + * Return true if halObj has a value for `page` with properties + * `size`, `totalElements`, `totalPages`, `number` + * + * @param {any} halObj The object to test + */ +export function isRestPaginatedList(halObj: any): boolean { + return hasValue(halObj.page) && + hasValue(halObj.page.size) && + hasValue(halObj.page.totalElements) && + hasValue(halObj.page.totalPages) && + hasValue(halObj.page.number); +} + export abstract class BaseResponseParsingService { protected abstract EnvConfig: GlobalConfig; protected abstract objectCache: ObjectCacheService; protected abstract toCache: boolean; + protected shouldDirectlyAttachEmbeds = false; + protected serializerConstructor: GenericConstructor> = DSpaceSerializer; protected process(data: any, request: RestRequest): any { if (isNotEmpty(data)) { @@ -33,20 +57,20 @@ export abstract class BaseResponseParsingService { .filter((property) => data._embedded.hasOwnProperty(property)) .forEach((property) => { const parsedObj = this.process(data._embedded[property], request); - if (isNotEmpty(parsedObj)) { - if (isRestPaginatedList(data._embedded[property])) { - object[property] = parsedObj; - object[property].page = parsedObj.page.map((obj) => this.retrieveObjectOrUrl(obj)); - } else if (isRestDataObject(data._embedded[property])) { - object[property] = this.retrieveObjectOrUrl(parsedObj); - } else if (Array.isArray(parsedObj)) { - object[property] = parsedObj.map((obj) => this.retrieveObjectOrUrl(obj)) - } + 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)); + } else if (isRestDataObject(data._embedded[property])) { + object[property] = this.retrieveObjectOrUrl(parsedObj); + } else if (Array.isArray(parsedObj)) { + object[property] = parsedObj.map((obj) => this.retrieveObjectOrUrl(obj)) + } } }); } - this.cache(object, request); + this.cache(object, request, data); return object; } const result = {}; @@ -87,33 +111,41 @@ export abstract class BaseResponseParsingService { protected deserialize(obj): any { const type: string = obj.type; if (hasValue(type)) { - const normObjConstructor = getMapsToType(type) as GenericConstructor; + const objConstructor = getClassForType(type) as GenericConstructor; - if (hasValue(normObjConstructor)) { - const serializer = new DSpaceRESTv2Serializer(normObjConstructor); + if (hasValue(objConstructor)) { + const serializer = new this.serializerConstructor(objConstructor); return serializer.deserialize(obj); } else { - // TODO: move check to Validator? - // throw new Error(`The server returned an object with an unknown a known type: ${type}`); + console.warn('cannot deserialize type ' + type); return null; } } else { - // TODO: move check to Validator - // throw new Error(`The server returned an object without a type: ${JSON.stringify(obj)}`); + console.warn('cannot deserialize type ' + type); return null; } } - protected cache(obj, request: RestRequest) { + protected cache(obj, request: RestRequest, data: any) { if (this.toCache) { - this.addToObjectCache(obj, request); + this.addToObjectCache(obj, request, data); } } - protected addToObjectCache(co: CacheableObject, request: RestRequest): void { - if (hasNoValue(co) || hasNoValue(co.self)) { - throw new Error('The server returned an invalid object'); + protected addToObjectCache(co: CacheableObject, request: RestRequest, data: any): void { + if (hasNoValue(co) || hasNoValue(co._links) || hasNoValue(co._links.self) || hasNoValue(co._links.self.href)) { + const type = hasValue(data) && hasValue(data.type) ? data.type : 'object'; + let dataJSON: string; + if (hasValue(data._embedded)) { + dataJSON = JSON.stringify(Object.assign({}, data, { + _embedded: '...' + })); + } else { + dataJSON = JSON.stringify(data); + } + 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); } @@ -121,7 +153,7 @@ export abstract class BaseResponseParsingService { processPageInfo(payload: any): PageInfo { if (hasValue(payload.page)) { const pageObj = Object.assign({}, payload.page, { _links: payload._links }); - const pageInfoObject = new DSpaceRESTv2Serializer(PageInfo).deserialize(pageObj); + const pageInfoObject = new DSpaceSerializer(PageInfo).deserialize(pageObj); if (pageInfoObject.currentPage >= 0) { Object.assign(pageInfoObject, { currentPage: pageInfoObject.currentPage + 1 }); } @@ -140,7 +172,7 @@ export abstract class BaseResponseParsingService { } protected retrieveObjectOrUrl(obj: any): any { - return this.toCache ? obj.self : obj; + return this.toCache ? obj._links.self.href : obj; } protected isSuccessStatus(statusCode: number) { diff --git a/src/app/core/data/bitstream-data.service.spec.ts b/src/app/core/data/bitstream-data.service.spec.ts new file mode 100644 index 0000000000..fca0f6b650 --- /dev/null +++ b/src/app/core/data/bitstream-data.service.spec.ts @@ -0,0 +1,58 @@ +import { BitstreamDataService } from './bitstream-data.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { RequestService } from './request.service'; +import { Bitstream } from '../shared/bitstream.model'; +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { BitstreamFormatDataService } from './bitstream-format-data.service'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { BitstreamFormat } from '../shared/bitstream-format.model'; +import { BitstreamFormatSupportLevel } from '../shared/bitstream-format-support-level'; +import { PutRequest } from './request.models'; + +describe('BitstreamDataService', () => { + let service: BitstreamDataService; + let objectCache: ObjectCacheService; + let requestService: RequestService; + let halService: HALEndpointService; + let bitstreamFormatService: BitstreamFormatDataService; + const bitstreamFormatHref = 'rest-api/bitstreamformats'; + + const bitstream = Object.assign(new Bitstream(), { + uuid: 'fake-bitstream', + _links: { + self: { href: 'fake-bitstream-self' } + } + }); + const format = Object.assign(new BitstreamFormat(), { + id: '2', + shortDescription: 'PNG', + description: 'Portable Network Graphics', + supportLevel: BitstreamFormatSupportLevel.Known + }); + const url = 'fake-bitstream-url'; + + beforeEach(() => { + objectCache = jasmine.createSpyObj('objectCache', { + remove: jasmine.createSpy('remove') + }); + requestService = getMockRequestService(); + halService = Object.assign(new HALEndpointServiceStub(url)); + bitstreamFormatService = jasmine.createSpyObj('bistreamFormatService', { + getBrowseEndpoint: observableOf(bitstreamFormatHref) + }); + + service = new BitstreamDataService(requestService, null, null, null, objectCache, halService, null, null, null, null, bitstreamFormatService); + }); + + describe('when updating the bitstream\'s format', () => { + beforeEach(() => { + service.updateFormat(bitstream, format); + }); + + it('should configure a put request', () => { + expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(PutRequest)); + }); + }); +}); diff --git a/src/app/core/data/bitstream-data.service.ts b/src/app/core/data/bitstream-data.service.ts new file mode 100644 index 0000000000..4c24f5d78b --- /dev/null +++ b/src/app/core/data/bitstream-data.service.ts @@ -0,0 +1,210 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs/internal/Observable'; +import { map, switchMap, take } from 'rxjs/operators'; +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'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { CoreState } from '../core.reducers'; +import { Bitstream } from '../shared/bitstream.model'; +import { BITSTREAM } from '../shared/bitstream.resource-type'; +import { Bundle } from '../shared/bundle.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { Item } from '../shared/item.model'; +import { BundleDataService } from './bundle-data.service'; +import { CommunityDataService } from './community-data.service'; +import { DataService } from './data.service'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { PaginatedList } from './paginated-list'; +import { RemoteData } from './remote-data'; +import { RemoteDataError } from './remote-data-error'; +import { FindListOptions, PutRequest } from './request.models'; +import { RequestService } from './request.service'; +import { BitstreamFormatDataService } from './bitstream-format-data.service'; +import { BitstreamFormat } from '../shared/bitstream-format.model'; +import { RestResponse } from '../cache/response.models'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { configureRequest, getResponseFromEntry } from '../shared/operators'; +import { combineLatest as observableCombineLatest } from 'rxjs'; + +/** + * A service to retrieve {@link Bitstream}s from the REST API + */ +@Injectable({ + providedIn: 'root' +}) +@dataService(BITSTREAM) +export class BitstreamDataService extends DataService { + + /** + * The HAL path to the bitstream endpoint + */ + protected linkPath = 'bitstreams'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected cds: CommunityDataService, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DSOChangeAnalyzer, + protected bundleService: BundleDataService, + protected bitstreamFormatService: BitstreamFormatDataService + ) { + super(); + } + + /** + * Retrieves the {@link Bitstream}s in a given bundle + * + * @param bundle the bundle to retrieve bitstreams from + * @param options options for the find all request + */ + findAllByBundle(bundle: Bundle, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { + return this.findAllByHref(bundle._links.bitstreams.href, options, ...linksToFollow); + } + + /** + * Retrieves the thumbnail for the given item + * @returns {Observable>} the first bitstream in the THUMBNAIL bundle + */ + // TODO should be implemented rest side. {@link Item} should get a thumbnail link + public getThumbnailFor(item: Item): Observable> { + return this.bundleService.findByItemAndName(item, 'THUMBNAIL').pipe( + switchMap((bundleRD: RemoteData) => { + if (isNotEmpty(bundleRD.payload)) { + return this.findAllByBundle(bundleRD.payload, { elementsPerPage: 1 }).pipe( + map((bitstreamRD: RemoteData>) => { + if (hasValue(bitstreamRD.payload) && hasValue(bitstreamRD.payload.page)) { + return new RemoteData( + false, + false, + true, + undefined, + bitstreamRD.payload.page[0] + ); + } else { + return bitstreamRD as any; + } + }) + ); + } else { + return [bundleRD as any]; + } + }) + ); + } + + /** + * Retrieve the matching thumbnail for a {@link Bitstream}. + * + * The {@link Item} is technically redundant, but is available + * in all current use cases, and having it simplifies this method + * + * @param item The {@link Item} the {@link Bitstream} and its thumbnail are a part of + * @param bitstreamInOriginal The original {@link Bitstream} to find the thumbnail for + */ + // TODO should be implemented rest side + public getMatchingThumbnail(item: Item, bitstreamInOriginal: Bitstream): Observable> { + return this.bundleService.findByItemAndName(item, 'THUMBNAIL').pipe( + switchMap((bundleRD: RemoteData) => { + 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)) { + const matchingThumbnail = bitstreamRD.payload.page.find((thumbnail: Bitstream) => + thumbnail.name.startsWith(bitstreamInOriginal.name) + ); + if (hasValue(matchingThumbnail)) { + return new RemoteData( + false, + false, + true, + undefined, + matchingThumbnail + ); + } else { + return new RemoteData( + false, + false, + false, + new RemoteDataError(404, '404', 'No matching thumbnail found'), + undefined + ); + } + } else { + return bitstreamRD as any; + } + }) + ); + } else { + return [bundleRD as any]; + } + }) + ); + } + + /** + * Retrieve all {@link Bitstream}s in a certain {@link Bundle}. + * + * The {@link Item} is technically redundant, but is available + * in all current use cases, and having it simplifies this method + * + * @param item the {@link Item} the {@link Bundle} is a part of + * @param bundleName the name of the {@link Bundle} we want to find {@link Bitstream}s for + * @param options the {@link FindListOptions} for the request + * @param linksToFollow the {@link FollowLinkConfig}s for the request + */ + public findAllByItemAndBundleName(item: Item, bundleName: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { + return this.bundleService.findByItemAndName(item, bundleName).pipe( + switchMap((bundleRD: RemoteData) => { + if (hasValue(bundleRD.payload)) { + return this.findAllByBundle(bundleRD.payload, options, ...linksToFollow); + } else { + return [bundleRD as any]; + } + }) + ); + } + + /** + * Set the format of a bitstream + * @param bitstream + * @param format + */ + updateFormat(bitstream: Bitstream, format: BitstreamFormat): Observable { + const requestId = this.requestService.generateRequestId(); + const bitstreamHref$ = this.getBrowseEndpoint().pipe( + map((href: string) => `${href}/${bitstream.id}`), + switchMap((href: string) => this.halService.getEndpoint('format', href)) + ); + const formatHref$ = this.bitstreamFormatService.getBrowseEndpoint().pipe( + map((href: string) => `${href}/${format.id}`) + ); + observableCombineLatest([bitstreamHref$, formatHref$]).pipe( + map(([bitstreamHref, formatHref]) => { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + return new PutRequest(requestId, bitstreamHref, formatHref, options); + }), + configureRequest(this.requestService), + take(1) + ).subscribe(() => { + this.requestService.removeByHrefSubstring(bitstream.self + '/format'); + }); + + return this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry() + ); + } + +} 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 c626fcd6e2..7954416010 100644 --- a/src/app/core/data/bitstream-format-data.service.spec.ts +++ b/src/app/core/data/bitstream-format-data.service.spec.ts @@ -8,7 +8,6 @@ import { cold, getTestScheduler, hot } from 'jasmine-marbles'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { BitstreamFormat } from '../shared/bitstream-format.model'; import { async } from '@angular/core/testing'; @@ -48,14 +47,12 @@ describe('BitstreamFormatDataService', () => { const notificationsService = {} as NotificationsService; const http = {} as HttpClient; const comparator = {} as any; - const dataBuildService = {} as NormalizedObjectBuildService; const rdbService = {} as RemoteDataBuildService; function initTestService(halService) { return new BitstreamFormatDataService( requestService, rdbService, - dataBuildService, store, objectCache, halService, @@ -285,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 c30330a0a3..e8cf030a52 100644 --- a/src/app/core/data/bitstream-format-data.service.ts +++ b/src/app/core/data/bitstream-format-data.service.ts @@ -1,31 +1,34 @@ -import { Injectable } from '@angular/core'; -import { DataService } from './data.service'; -import { BitstreamFormat } from '../shared/bitstream-format.model'; -import { RequestService } from './request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; -import { createSelector, select, Store } from '@ngrx/store'; -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 { DeleteByIDRequest, FindListOptions, PostRequest, PutRequest } from './request.models'; +import { Injectable } from '@angular/core'; +import { createSelector, select, Store } from '@ngrx/store'; import { Observable } from 'rxjs'; -import { find, map, tap } from 'rxjs/operators'; -import { configureRequest, getResponseFromEntry } from '../shared/operators'; import { distinctUntilChanged } from 'rxjs/internal/operators/distinctUntilChanged'; -import { RestResponse } from '../cache/response.models'; -import { BitstreamFormatRegistryState } from '../../+admin/admin-registries/bitstream-formats/bitstream-format.reducers'; +import { find, map, tap } from 'rxjs/operators'; import { BitstreamFormatsRegistryDeselectAction, BitstreamFormatsRegistryDeselectAllAction, BitstreamFormatsRegistrySelectAction } from '../../+admin/admin-registries/bitstream-formats/bitstream-format.actions'; +import { BitstreamFormatRegistryState } from '../../+admin/admin-registries/bitstream-formats/bitstream-format.reducers'; import { hasValue } from '../../shared/empty.util'; -import { RequestEntry } from './request.reducer'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { dataService } from '../cache/builders/build-decorators'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { RestResponse } from '../cache/response.models'; import { CoreState } from '../core.reducers'; import { coreSelector } from '../core.selectors'; +import { BitstreamFormat } from '../shared/bitstream-format.model'; +import { BITSTREAM_FORMAT } from '../shared/bitstream-format.resource-type'; +import { Bitstream } from '../shared/bitstream.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { configureRequest, getResponseFromEntry } from '../shared/operators'; +import { DataService } from './data.service'; +import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { RemoteData } from './remote-data'; +import { DeleteByIDRequest, PostRequest, PutRequest } from './request.models'; +import { RequestEntry } from './request.reducer'; +import { RequestService } from './request.service'; const bitstreamFormatsStateSelector = createSelector( coreSelector, @@ -38,6 +41,7 @@ const selectedBitstreamFormatSelector = createSelector(bitstreamFormatsStateSele * A service responsible for fetching/sending data from/to the REST API on the bitstreamformats endpoint */ @Injectable() +@dataService(BITSTREAM_FORMAT) export class BitstreamFormatDataService extends DataService { protected linkPath = 'bitstreamformats'; @@ -45,7 +49,6 @@ export class BitstreamFormatDataService extends DataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, @@ -55,16 +58,6 @@ export class BitstreamFormatDataService extends DataService { super(); } - /** - * Get the endpoint for browsing bitstream formats - * @param {FindListOptions} options - * @param {string} linkPath - * @returns {Observable} - */ - getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable { - return this.halService.getEndpoint(this.linkPath); - } - /** * Get the endpoint to update an existing bitstream format * @param formatId @@ -161,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(); @@ -183,4 +176,8 @@ export class BitstreamFormatDataService extends DataService { map((request: RequestEntry) => request.response.isSuccessful) ); } + + findByBitstream(bitstream: Bitstream): Observable> { + return this.findByHref(bitstream._links.format.href); + } } diff --git a/src/app/core/data/browse-entries-response-parsing.service.ts b/src/app/core/data/browse-entries-response-parsing.service.ts index a2f5f21312..ec35b8cc75 100644 --- a/src/app/core/data/browse-entries-response-parsing.service.ts +++ b/src/app/core/data/browse-entries-response-parsing.service.ts @@ -5,11 +5,11 @@ import { isNotEmpty } from '../../shared/empty.util'; import { ObjectCacheService } from '../cache/object-cache.service'; import { ErrorResponse, GenericSuccessResponse, RestResponse } from '../cache/response.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; +import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; +import { BrowseEntry } from '../shared/browse-entry.model'; import { BaseResponseParsingService } from './base-response-parsing.service'; import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; -import { NormalizedBrowseEntry } from '../shared/normalized-browse-entry.model'; @Injectable() export class BrowseEntriesResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { @@ -26,7 +26,7 @@ export class BrowseEntriesResponseParsingService extends BaseResponseParsingServ if (isNotEmpty(data.payload)) { let browseEntries = []; if (isNotEmpty(data.payload._embedded) && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) { - const serializer = new DSpaceRESTv2Serializer(NormalizedBrowseEntry); + const serializer = new DSpaceSerializer(BrowseEntry); browseEntries = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); } return new GenericSuccessResponse(browseEntries, data.statusCode, data.statusText, this.processPageInfo(data.payload)); diff --git a/src/app/core/data/browse-items-response-parsing-service.ts b/src/app/core/data/browse-items-response-parsing-service.ts index 324b36199a..08ade5772d 100644 --- a/src/app/core/data/browse-items-response-parsing-service.ts +++ b/src/app/core/data/browse-items-response-parsing-service.ts @@ -6,12 +6,11 @@ import { hasValue, isNotEmpty } from '../../shared/empty.util'; import { ObjectCacheService } from '../cache/object-cache.service'; import { ErrorResponse, GenericSuccessResponse, RestResponse } from '../cache/response.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; +import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; +import { DSpaceObject } from '../shared/dspace-object.model'; import { BaseResponseParsingService } from './base-response-parsing.service'; import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model'; /** * A ResponseParsingService used to parse DSpaceRESTV2Response coming from the REST API to Browse Items (DSpaceObject[]) @@ -35,7 +34,7 @@ export class BrowseItemsResponseParsingService extends BaseResponseParsingServic parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { if (isNotEmpty(data.payload) && isNotEmpty(data.payload._embedded) && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) { - const serializer = new DSpaceRESTv2Serializer(NormalizedDSpaceObject); + const serializer = new DSpaceSerializer(DSpaceObject); const items = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); return new GenericSuccessResponse(items, data.statusCode, data.statusText, this.processPageInfo(data.payload)); } else if (hasValue(data.payload) && hasValue(data.payload.page)) { diff --git a/src/app/core/data/browse-response-parsing.service.spec.ts b/src/app/core/data/browse-response-parsing.service.spec.ts index 8d0fe7cd41..fedfea1309 100644 --- a/src/app/core/data/browse-response-parsing.service.spec.ts +++ b/src/app/core/data/browse-response-parsing.service.spec.ts @@ -1,8 +1,8 @@ +import { ErrorResponse, GenericSuccessResponse } from '../cache/response.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { BrowseDefinition } from '../shared/browse-definition.model'; import { BrowseResponseParsingService } from './browse-response-parsing.service'; import { BrowseEndpointRequest } from './request.models'; -import { GenericSuccessResponse, ErrorResponse } from '../cache/response.models'; -import { BrowseDefinition } from '../shared/browse-definition.model'; -import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; describe('BrowseResponseParsingService', () => { let service: BrowseResponseParsingService; @@ -31,7 +31,6 @@ describe('BrowseResponseParsingService', () => { metadata: 'dc.date.issued' }, { name: 'dateaccessioned', metadata: 'dc.date.accessioned' }], order: 'ASC', - type: 'browse', metadata: ['dc.date.issued'], _links: { self: { href: 'https://rest.api/discover/browses/dateissued' }, @@ -44,7 +43,6 @@ describe('BrowseResponseParsingService', () => { metadata: 'dc.date.issued' }, { name: 'dateaccessioned', metadata: 'dc.date.accessioned' }], order: 'ASC', - type: 'browse', metadata: ['dc.contributor.*', 'dc.creator'], _links: { self: { href: 'https://rest.api/discover/browses/author' }, @@ -68,7 +66,6 @@ describe('BrowseResponseParsingService', () => { metadata: 'dc.date.issued' }, { name: 'dateaccessioned', metadata: 'dc.date.accessioned' }], order: 'ASC', - type: 'browse', metadata: ['dc.date.issued'], _links: { self: { href: 'https://rest.api/discover/browses/dateissued' }, @@ -117,8 +114,8 @@ describe('BrowseResponseParsingService', () => { 'dc.date.issued' ], _links: { - self: 'https://rest.api/discover/browses/dateissued', - items: 'https://rest.api/discover/browses/dateissued/items' + self: { href: 'https://rest.api/discover/browses/dateissued' }, + items: { href: 'https://rest.api/discover/browses/dateissued/items' } } }), Object.assign(new BrowseDefinition(), { @@ -143,9 +140,9 @@ describe('BrowseResponseParsingService', () => { 'dc.creator' ], _links: { - self: 'https://rest.api/discover/browses/author', - entries: 'https://rest.api/discover/browses/author/entries', - items: 'https://rest.api/discover/browses/author/items' + self: { href: 'https://rest.api/discover/browses/author' }, + entries: { href: 'https://rest.api/discover/browses/author/entries' }, + items: { href: 'https://rest.api/discover/browses/author/items' } } }) ]; diff --git a/src/app/core/data/browse-response-parsing.service.ts b/src/app/core/data/browse-response-parsing.service.ts index 3c67b2b3eb..d1b9c2f15c 100644 --- a/src/app/core/data/browse-response-parsing.service.ts +++ b/src/app/core/data/browse-response-parsing.service.ts @@ -1,11 +1,11 @@ import { Injectable } from '@angular/core'; +import { isNotEmpty } from '../../shared/empty.util'; +import { ErrorResponse, GenericSuccessResponse, RestResponse } from '../cache/response.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; +import { BrowseDefinition } from '../shared/browse-definition.model'; import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; -import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { GenericSuccessResponse, ErrorResponse, RestResponse } from '../cache/response.models'; -import { isNotEmpty } from '../../shared/empty.util'; -import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; -import { BrowseDefinition } from '../shared/browse-definition.model'; @Injectable() export class BrowseResponseParsingService implements ResponseParsingService { @@ -13,7 +13,7 @@ export class BrowseResponseParsingService implements ResponseParsingService { parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { if (isNotEmpty(data.payload) && isNotEmpty(data.payload._embedded) && Array.isArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]])) { - const serializer = new DSpaceRESTv2Serializer(BrowseDefinition); + const serializer = new DSpaceSerializer(BrowseDefinition); const browseDefinitions = serializer.deserializeArray(data.payload._embedded[Object.keys(data.payload._embedded)[0]]); return new GenericSuccessResponse(browseDefinitions, data.statusCode, data.statusText); } else { diff --git a/src/app/core/data/bundle-data.service.ts b/src/app/core/data/bundle-data.service.ts index 280f727aad..160ea0ff0d 100644 --- a/src/app/core/data/bundle-data.service.ts +++ b/src/app/core/data/bundle-data.service.ts @@ -1,31 +1,42 @@ -import { Injectable } from '@angular/core'; -import { DataService } from './data.service'; -import { Bundle } from '../shared/bundle.model'; -import { RequestService } from './request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-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 { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; import { Observable } from 'rxjs/internal/Observable'; +import { map, switchMap, take } 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 { dataService } from '../cache/builders/build-decorators'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { CoreState } from '../core.reducers'; +import { Bundle } from '../shared/bundle.model'; +import { BUNDLE } from '../shared/bundle.resource-type'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { Item } from '../shared/item.model'; +import { DataService } from './data.service'; +import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { PaginatedList } from './paginated-list'; +import { RemoteData } from './remote-data'; +import { FindListOptions, GetRequest } from './request.models'; +import { RequestService } from './request.service'; +import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; +import { Bitstream } from '../shared/bitstream.model'; /** - * A service responsible for fetching/sending data from/to the REST API on the bundles endpoint + * A service to retrieve {@link Bundle}s from the REST API */ -@Injectable() +@Injectable( + {providedIn: 'root'} +) +@dataService(BUNDLE) export class BundleDataService extends DataService { protected linkPath = 'bundles'; - protected forceBypassCache = false; + protected bitstreamsEndpoint = 'bitstreams'; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, @@ -36,11 +47,71 @@ export class BundleDataService extends DataService { } /** - * Get the endpoint for browsing bundles - * @param {FindListOptions} options - * @returns {Observable} + * Retrieve all {@link Bundle}s in the given {@link Item} + * + * @param item the {@link Item} the {@link Bundle}s are a part of + * @param options the {@link FindListOptions} for the request + * @param linksToFollow the {@link FollowLinkConfig}s for the request */ - getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable { - return this.halService.getEndpoint(this.linkPath); + findAllByItem(item: Item, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { + return this.findAllByHref(item._links.bundles.href, options, ...linksToFollow); + } + + /** + * Retrieve a {@link Bundle} in the given {@link Item} by name + * + * @param item the {@link Item} the {@link Bundle}s are a part of + * @param bundleName the name of the {@link Bundle} to retrieve + * @param linksToFollow the {@link FollowLinkConfig}s for the request + */ + // TODO should be implemented rest side + findByItemAndName(item: Item, bundleName: string, ...linksToFollow: Array>): Observable> { + return this.findAllByItem(item, { elementsPerPage: Number.MAX_SAFE_INTEGER }, ...linksToFollow).pipe( + map((rd: RemoteData>) => { + if (hasValue(rd.payload) && hasValue(rd.payload.page)) { + const matchingBundle = rd.payload.page.find((bundle: Bundle) => + bundle.name === bundleName); + return new RemoteData( + false, + false, + true, + undefined, + matchingBundle + ); + } else { + return rd as any; + } + }), + ); + } + + /** + * Get the bitstreams endpoint for a bundle + * @param bundleId + */ + getBitstreamsEndpoint(bundleId: string): Observable { + return this.getBrowseEndpoint().pipe( + switchMap((href: string) => this.halService.getEndpoint(this.bitstreamsEndpoint, `${href}/${bundleId}`)) + ); + } + + /** + * Get a bundle's bitstreams using paginated search options + * @param bundleId The bundle's ID + * @param searchOptions The search options to use + * @param linksToFollow The {@link FollowLinkConfig}s for the request + */ + getBitstreams(bundleId: string, searchOptions?: PaginatedSearchOptions, ...linksToFollow: Array>): Observable>> { + const hrefObs = this.getBitstreamsEndpoint(bundleId).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/data/change-analyzer.ts b/src/app/core/data/change-analyzer.ts index c45c9e55b7..395af4a68c 100644 --- a/src/app/core/data/change-analyzer.ts +++ b/src/app/core/data/change-analyzer.ts @@ -1,4 +1,3 @@ -import { NormalizedObject } from '../cache/models/normalized-object.model'; import { Operation } from 'fast-json-patch/lib/core'; import { CacheableObject } from '../cache/object-cache.reducer'; @@ -12,10 +11,10 @@ export interface ChangeAnalyzer { * Compare two objects and return their differences as a * JsonPatch Operation Array * - * @param {NormalizedObject} object1 + * @param {CacheableObject} object1 * The first object to compare - * @param {NormalizedObject} object2 + * @param {CacheableObject} object2 * The second object to compare */ - diff(object1: T | NormalizedObject, object2: T | NormalizedObject): Operation[]; + diff(object1: T, object2: T): Operation[]; } diff --git a/src/app/core/data/collection-data.service.spec.ts b/src/app/core/data/collection-data.service.spec.ts index a3e1a916e3..96141d6a8a 100644 --- a/src/app/core/data/collection-data.service.spec.ts +++ b/src/app/core/data/collection-data.service.spec.ts @@ -1,44 +1,132 @@ import { CollectionDataService } from './collection-data.service'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; -import { getMockRequestService } from '../../shared/mocks/mock-request.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RequestService } from './request.service'; +import { TranslateService } from '@ngx-translate/core'; +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub'; +import { getMockTranslateService } from '../../shared/mocks/mock-translate.service'; +import { fakeAsync, tick } from '@angular/core/testing'; +import { ContentSourceRequest, GetRequest, RequestError, UpdateContentSourceRequest } from './request.models'; +import { ContentSource } from '../shared/content-source.model'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { RequestEntry } from './request.reducer'; +import { ErrorResponse, RestResponse } from '../cache/response.models'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { GetRequest } from './request.models'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +const url = 'fake-url'; +const collectionId = 'fake-collection-id'; + describe('CollectionDataService', () => { let service: CollectionDataService; - let objectCache: ObjectCacheService; + let requestService: RequestService; - let halService: HALEndpointService; + let translate: TranslateService; + let notificationsService: any; let rdbService: RemoteDataBuildService; + let objectCache: ObjectCacheService; + let halService: any; - const url = 'fake-collections-url'; - - beforeEach(() => { - objectCache = jasmine.createSpyObj('objectCache', { - remove: jasmine.createSpy('remove') + describe('when the requests are successful', () => { + beforeEach(() => { + createService(); }); - requestService = getMockRequestService(); - halService = Object.assign(new HALEndpointServiceStub(url)); + + describe('when calling getContentSource', () => { + let contentSource$; + + beforeEach(() => { + contentSource$ = service.getContentSource(collectionId); + }); + + it('should configure a new ContentSourceRequest', fakeAsync(() => { + contentSource$.subscribe(); + tick(); + expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(ContentSourceRequest)); + })); + }); + + describe('when calling updateContentSource', () => { + let returnedContentSource$; + let contentSource; + + beforeEach(() => { + contentSource = new ContentSource(); + returnedContentSource$ = service.updateContentSource(collectionId, contentSource); + }); + + it('should configure a new UpdateContentSourceRequest', fakeAsync(() => { + returnedContentSource$.subscribe(); + tick(); + expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(UpdateContentSourceRequest)); + })); + }); + + describe('getMappedItems', () => { + let result; + + beforeEach(() => { + result = service.getMappedItems('collection-id'); + }); + + it('should configure a GET request', () => { + expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(GetRequest)); + }); + }); + + }); + + describe('when the requests are unsuccessful', () => { + beforeEach(() => { + createService(observableOf(Object.assign(new RequestEntry(), { + response: new ErrorResponse(Object.assign({ + statusCode: 422, + statusText: 'Unprocessable Entity', + message: 'Error message' + })) + }))); + }); + + describe('when calling updateContentSource', () => { + let returnedContentSource$; + let contentSource; + + beforeEach(() => { + contentSource = new ContentSource(); + returnedContentSource$ = service.updateContentSource(collectionId, contentSource); + }); + + it('should configure a new UpdateContentSourceRequest', fakeAsync(() => { + returnedContentSource$.subscribe(); + tick(); + expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(UpdateContentSourceRequest)); + })); + + it('should display an error notification', fakeAsync(() => { + returnedContentSource$.subscribe(); + tick(); + expect(notificationsService.error).toHaveBeenCalled(); + })); + }); + }); + + /** + * Create a CollectionDataService 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') }); - - service = new CollectionDataService(requestService, rdbService, null, null, null, objectCache, halService, null, null, null); - }); - - describe('getMappedItems', () => { - let result; - - beforeEach(() => { - result = service.getMappedItems('collection-id'); + objectCache = jasmine.createSpyObj('objectCache', { + remove: jasmine.createSpy('remove') }); + halService = new HALEndpointServiceStub(url); + notificationsService = new NotificationsServiceStub(); + translate = getMockTranslateService(); - it('should configure a GET request', () => { - expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(GetRequest)); - }); - }); + service = new CollectionDataService(requestService, rdbService, null, null, objectCache, halService, notificationsService, null, null, translate); + } }); diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index 0c032e6766..6ae40f4ca9 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -1,48 +1,70 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; - -import { distinctUntilChanged, filter, map, switchMap, take } from 'rxjs/operators'; import { Store } from '@ngrx/store'; - +import { TranslateService } from '@ngx-translate/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { distinctUntilChanged, filter, map, switchMap, take } from 'rxjs/operators'; +import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; +import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; +import { INotification } from '../../shared/notifications/models/notification.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { SearchParam } from '../cache/models/search-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { ContentSourceSuccessResponse, RestResponse } from '../cache/response.models'; import { CoreState } from '../core.reducers'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; import { Collection } from '../shared/collection.model'; +import { COLLECTION } from '../shared/collection.resource-type'; +import { ContentSource } from '../shared/content-source.model'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { Item } from '../shared/item.model'; +import { + configureRequest, + filterSuccessfulResponses, + getRequestFromRequestHref, + getResponseFromEntry +} from '../shared/operators'; import { ComColDataService } from './comcol-data.service'; import { CommunityDataService } from './community-data.service'; -import { RequestService } from './request.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { Observable } from 'rxjs/internal/Observable'; -import {FindListOptions, FindListRequest, GetRequest} from './request.models'; -import { RemoteData } from './remote-data'; -import { PaginatedList } from './paginated-list'; -import { configureRequest } from '../shared/operators'; import { DSOResponseParsingService } from './dso-response-parsing.service'; +import { PaginatedList } from './paginated-list'; import { ResponseParsingService } from './parsing.service'; -import { GenericConstructor } from '../shared/generic-constructor'; -import { hasValue, isNotEmptyOperator } from '../../shared/empty.util'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { SearchParam } from '../cache/models/search-param.model'; -import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; +import { RemoteData } from './remote-data'; +import { + ContentSourceRequest, + FindListOptions, + GetRequest, + RestRequest, + UpdateContentSourceRequest +} from './request.models'; +import { RequestService } from './request.service'; @Injectable() +@dataService(COLLECTION) export class CollectionDataService extends ComColDataService { protected linkPath = 'collections'; + protected errorTitle = 'collection.source.update.notifications.error.title'; + protected contentSourceError = 'collection.source.update.notifications.error.content'; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected cds: CommunityDataService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: DSOChangeAnalyzer + protected comparator: DSOChangeAnalyzer, + protected translate: TranslateService ) { super(); } @@ -97,6 +119,81 @@ export class CollectionDataService extends ComColDataService { ); } + /** + * Get the endpoint for the collection's content harvester + * @param collectionId + */ + getHarvesterEndpoint(collectionId: string): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + switchMap((href: string) => this.halService.getEndpoint('harvester', `${href}/${collectionId}`)) + ); + } + + /** + * Get the collection's content harvester + * @param collectionId + */ + getContentSource(collectionId: string): Observable { + return this.getHarvesterEndpoint(collectionId).pipe( + map((href: string) => new ContentSourceRequest(this.requestService.generateRequestId(), href)), + configureRequest(this.requestService), + map((request: RestRequest) => request.href), + getRequestFromRequestHref(this.requestService), + filterSuccessfulResponses(), + map((response: ContentSourceSuccessResponse) => response.contentsource) + ); + } + + /** + * Update the settings of the collection's content harvester + * @param collectionId + * @param contentSource + */ + updateContentSource(collectionId: string, contentSource: ContentSource): Observable { + const requestId = this.requestService.generateRequestId(); + const serializedContentSource = new DSpaceSerializer(ContentSource).serialize(contentSource); + const request$ = this.getHarvesterEndpoint(collectionId).pipe( + take(1), + map((href: string) => { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'application/json'); + options.headers = headers; + return new UpdateContentSourceRequest(requestId, href, JSON.stringify(serializedContentSource), options); + }) + ); + + // Execute the post/put request + request$.pipe( + configureRequest(this.requestService) + ).subscribe(); + + // Return updated ContentSource + return this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry(), + map((response: RestResponse) => { + if (!response.isSuccessful) { + if (hasValue((response as any).errorMessage)) { + if (response.statusCode === 422) { + return this.notificationsService.error(this.translate.instant(this.errorTitle), this.translate.instant(this.contentSourceError), new NotificationOptions(-1)); + } else { + return this.notificationsService.error(this.translate.instant(this.errorTitle), (response as any).errorMessage, new NotificationOptions(-1)); + } + } + } else { + return response; + } + }), + isNotEmptyOperator(), + map((response: ContentSourceSuccessResponse | INotification) => { + if (isNotEmpty((response as any).contentsource)) { + return (response as ContentSourceSuccessResponse).contentsource; + } + return response as INotification; + }) + ); + } + /** * Fetches the endpoint used for mapping items to a collection * @param collectionId The id of the collection to map items to @@ -112,8 +209,9 @@ export class CollectionDataService extends ComColDataService { * Fetches a list of items that are mapped to a collection * @param collectionId The id of the collection * @param searchOptions Search options to sort or filter out items + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ - getMappedItems(collectionId: string, searchOptions?: PaginatedSearchOptions): Observable>> { + getMappedItems(collectionId: string, searchOptions?: PaginatedSearchOptions, ...linksToFollow: Array>): Observable>> { const requestUuid = this.requestService.generateRequestId(); const href$ = this.getMappedItemsEndpoint(collectionId).pipe( @@ -135,7 +233,7 @@ export class CollectionDataService extends ComColDataService { configureRequest(this.requestService) ).subscribe(); - return this.rdbService.buildList(href$); + return this.rdbService.buildList(href$, ...linksToFollow); } protected getFindByParentHref(parentUUID: string): Observable { @@ -144,4 +242,13 @@ export class CollectionDataService extends ComColDataService { this.halService.getEndpoint('collections', `${communityEndpointHref}/${parentUUID}`)), ); } + + /** + * Returns {@link RemoteData} of {@link Collection} that is the owing collection of the given item + * @param item Item we want the owning collection of + */ + findOwningCollectionFor(item: Item): Observable> { + return this.findByHref(item._links.owningCollection.href); + } + } diff --git a/src/app/core/data/comcol-data.service.spec.ts b/src/app/core/data/comcol-data.service.spec.ts index a7fcd205d4..fc487527b9 100644 --- a/src/app/core/data/comcol-data.service.spec.ts +++ b/src/app/core/data/comcol-data.service.spec.ts @@ -1,38 +1,31 @@ +import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; import { cold, getTestScheduler, hot } from 'jasmine-marbles'; +import { Observable, of as observableOf } from 'rxjs'; import { TestScheduler } from 'rxjs/testing'; import { GlobalConfig } from '../../../config'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { CoreState } from '../core.reducers'; +import { Community } from '../shared/community.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { Item } from '../shared/item.model'; import { ComColDataService } from './comcol-data.service'; import { CommunityDataService } from './community-data.service'; -import { FindListOptions, FindByIDRequest } from './request.models'; -import { RequestService } from './request.service'; -import { NormalizedObject } from '../cache/models/normalized-object.model'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RequestEntry } from './request.reducer'; -import {Observable, of as observableOf} from 'rxjs'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { Item } from '../shared/item.model'; -import { Community } from '../shared/community.model'; +import { FindByIDRequest, FindListOptions } from './request.models'; +import { RequestEntry } from './request.reducer'; +import { RequestService } from './request.service'; const LINK_NAME = 'test'; -/* tslint:disable:max-classes-per-file */ -class NormalizedTestObject extends NormalizedObject { -} - class TestService extends ComColDataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected EnvConfig: GlobalConfig, protected cds: CommunityDataService, @@ -52,8 +45,6 @@ class TestService extends ComColDataService { } } -/* tslint:enable:max-classes-per-file */ - describe('ComColDataService', () => { let scheduler: TestScheduler; let service: TestService; @@ -68,7 +59,6 @@ describe('ComColDataService', () => { const notificationsService = {} as NotificationsService; const http = {} as HttpClient; const comparator = {} as any; - const dataBuildService = {} as NormalizedObjectBuildService; const scopeID = 'd9d30c0c-69b7-4369-8397-ca67c888974d'; const options = Object.assign(new FindListOptions(), { @@ -102,7 +92,9 @@ describe('ComColDataService', () => { getObjectByUUID: cold('d-', { d: { _links: { - [LINK_NAME]: scopedEndpoint + [LINK_NAME]: { + href: scopedEndpoint + } } } }) @@ -113,7 +105,6 @@ describe('ComColDataService', () => { return new TestService( requestService, rdbService, - dataBuildService, store, EnvConfig, cds, diff --git a/src/app/core/data/comcol-data.service.ts b/src/app/core/data/comcol-data.service.ts index 2ce0362a4e..d83518a3b0 100644 --- a/src/app/core/data/comcol-data.service.ts +++ b/src/app/core/data/comcol-data.service.ts @@ -6,8 +6,10 @@ import { } from 'rxjs/operators'; import { merge as observableMerge, Observable, throwError as observableThrowError, combineLatest as observableCombineLatest } from 'rxjs'; import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; -import { NormalizedCommunity } from '../cache/models/normalized-community.model'; import { ObjectCacheService } from '../cache/object-cache.service'; +import { Community } from '../shared/community.model'; +import { HALLink } from '../shared/hal-link.model'; +import { HALResource } from '../shared/hal-resource.model'; import { CommunityDataService } from './community-data.service'; import { DataService } from './data.service'; @@ -70,8 +72,9 @@ export abstract class ComColDataService extends DataS const successResponses = responses.pipe( filter((response) => response.isSuccessful), mergeMap(() => this.objectCache.getObjectByUUID(options.scopeID)), - map((nc: NormalizedCommunity) => nc._links[linkPath]), - filter((href) => isNotEmpty(href)) + map((hr: HALResource) => hr._links[linkPath]), + filter((halLink: HALLink) => isNotEmpty(halLink)), + map((halLink: HALLink) => halLink.href) ); return observableMerge(errorResponses, successResponses).pipe(distinctUntilChanged(), share()); @@ -81,7 +84,9 @@ export abstract class ComColDataService extends DataS protected abstract getFindByParentHref(parentUUID: string): Observable; public findByParent(parentUUID: string, options: FindListOptions = {}): Observable>> { - const href$ = this.buildHrefFromFindOptions(this.getFindByParentHref(parentUUID), [], options); + const href$ = this.getFindByParentHref(parentUUID).pipe( + map((href: string) => this.buildHrefFromFindOptions(href, options)) + ); return this.findList(href$, options); } diff --git a/src/app/core/data/community-data.service.ts b/src/app/core/data/community-data.service.ts index 57bf64678f..123c3eccd1 100644 --- a/src/app/core/data/community-data.service.ts +++ b/src/app/core/data/community-data.service.ts @@ -1,25 +1,27 @@ -import { filter, switchMap, take } from 'rxjs/operators'; +import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { filter, switchMap, take } from 'rxjs/operators'; +import { hasValue } from '../../shared/empty.util'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { CoreState } from '../core.reducers'; import { Community } from '../shared/community.model'; -import { ComColDataService } from './comcol-data.service'; -import { RequestService } from './request.service'; +import { COMMUNITY } from '../shared/community.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { FindListOptions, FindListRequest } from './request.models'; -import { RemoteData } from './remote-data'; -import { hasValue } from '../../shared/empty.util'; -import { Observable } from 'rxjs'; -import { PaginatedList } from './paginated-list'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { ComColDataService } from './comcol-data.service'; import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { PaginatedList } from './paginated-list'; +import { RemoteData } from './remote-data'; +import { FindListOptions, FindListRequest } from './request.models'; +import { RequestService } from './request.service'; @Injectable() +@dataService(COMMUNITY) export class CommunityDataService extends ComColDataService { protected linkPath = 'communities'; protected topLinkPath = 'communities/search/top'; @@ -28,7 +30,6 @@ export class CommunityDataService extends ComColDataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, diff --git a/src/app/core/data/content-source-response-parsing.service.ts b/src/app/core/data/content-source-response-parsing.service.ts new file mode 100644 index 0000000000..95e25db613 --- /dev/null +++ b/src/app/core/data/content-source-response-parsing.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@angular/core'; +import { ContentSourceSuccessResponse, RestResponse } from '../cache/response.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; +import { ContentSource } from '../shared/content-source.model'; +import { MetadataConfig } from '../shared/metadata-config.model'; +import { ResponseParsingService } from './parsing.service'; +import { RestRequest } from './request.models'; + +@Injectable() +/** + * A ResponseParsingService used to parse DSpaceRESTV2Response coming from the REST API to a ContentSource object + * wrapped in a ContentSourceSuccessResponse + */ +export class ContentSourceResponseParsingService implements ResponseParsingService { + + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + const payload = data.payload; + + const deserialized = new DSpaceSerializer(ContentSource).deserialize(payload); + + let metadataConfigs = []; + if (payload._embedded && payload._embedded.harvestermetadata && payload._embedded.harvestermetadata.configs) { + metadataConfigs = new DSpaceSerializer(MetadataConfig).serializeArray(payload._embedded.harvestermetadata.configs); + } + deserialized.metadataConfigs = metadataConfigs; + + return new ContentSourceSuccessResponse(deserialized, data.statusCode, data.statusText); + } + +} diff --git a/src/app/core/data/data.service.spec.ts b/src/app/core/data/data.service.spec.ts index ca5f2cc12e..f776dfea63 100644 --- a/src/app/core/data/data.service.spec.ts +++ b/src/app/core/data/data.service.spec.ts @@ -1,43 +1,40 @@ -import { DataService } from './data.service'; -import { NormalizedObject } from '../cache/models/normalized-object.model'; -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 { 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 { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; -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'; -// tslint:disable:max-classes-per-file -class NormalizedTestObject extends NormalizedObject { -} - +/* tslint:disable:max-classes-per-file */ class TestService extends DataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected linkPath: string, protected halService: HALEndpointService, protected objectCache: ObjectCacheService, protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: ChangeAnalyzer + protected comparator: ChangeAnalyzer ) { super(); } @@ -47,8 +44,8 @@ class TestService extends DataService { } } -class DummyChangeAnalyzer implements ChangeAnalyzer { - diff(object1: NormalizedTestObject, object2: NormalizedTestObject): Operation[] { +class DummyChangeAnalyzer implements ChangeAnalyzer { + diff(object1: Item, object2: Item): Operation[] { return compare((object1 as any).metadata, (object2 as any).metadata); } @@ -57,15 +54,12 @@ 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; const comparator = new DummyChangeAnalyzer() as any; - const dataBuildService = { - normalize: (object) => object - } as NormalizedObjectBuildService; const objectCache = { addPatch: () => { /* empty */ @@ -80,7 +74,6 @@ describe('DataService', () => { return new TestService( requestService, rdbService, - dataBuildService, store, endpoint, halService, @@ -155,21 +148,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)); }); }); @@ -184,13 +317,15 @@ describe('DataService', () => { operations = [{ op: 'replace', path: '/0/value', value: name2 } as Operation]; selfLink = 'https://rest.api/endpoint/1698f1d3-be98-4c51-9fd8-6bfedcbd59b7'; - dso = new DSpaceObject(); - dso.self = selfLink; - dso.metadata = [{ key: 'dc.title', value: name1 }]; + dso = Object.assign(new DSpaceObject(), { + _links: { self: { href: selfLink } }, + metadata: [{ key: 'dc.title', value: name1 }] + }); - dso2 = new DSpaceObject(); - dso2.self = selfLink; - dso2.metadata = [{ key: 'dc.title', value: name2 }]; + dso2 = Object.assign(new DSpaceObject(), { + _links: { self: { href: selfLink } }, + metadata: [{ key: 'dc.title', value: name2 }] + }); spyOn(service, 'findByHref').and.returnValue(createSuccessfulRemoteDataObject$(dso)); spyOn(objectCache, 'addPatch'); @@ -207,3 +342,4 @@ describe('DataService', () => { }); }); }); +/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 04d1990eac..0860bc5915 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -1,5 +1,6 @@ import { HttpClient } from '@angular/common/http'; - +import { Store } from '@ngrx/store'; +import { Operation } from 'fast-json-patch'; import { Observable } from 'rxjs'; import { distinctUntilChanged, @@ -13,12 +14,28 @@ import { take, tap } from 'rxjs/operators'; -import { Store } from '@ngrx/store'; - -import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; +import { hasValue, hasValueOperator, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; +import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { getClassForType } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { SearchParam } from '../cache/models/search-param.model'; +import { CacheableObject } from '../cache/object-cache.reducer'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { ErrorResponse, RestResponse } from '../cache/response.models'; +import { CoreState } from '../core.reducers'; +import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; +import { DSpaceObject } from '../shared/dspace-object.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { + configureRequest, + getRemoteDataPayload, + getResponseFromEntry, + getSucceededRemoteData +} from '../shared/operators'; import { URLCombiner } from '../url-combiner/url-combiner'; +import { ChangeAnalyzer } from './change-analyzer'; import { PaginatedList } from './paginated-list'; import { RemoteData } from './remote-data'; import { @@ -27,33 +44,17 @@ import { FindByIDRequest, FindListOptions, FindListRequest, - GetRequest + GetRequest, + PatchRequest } from './request.models'; -import { RequestService } from './request.service'; -import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; -import { NormalizedObject } from '../cache/models/normalized-object.model'; -import { SearchParam } from '../cache/models/search-param.model'; -import { Operation } from 'fast-json-patch'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { configureRequest, getRemoteDataPayload, getResponseFromEntry, getSucceededRemoteData } from '../shared/operators'; -import { ErrorResponse, RestResponse } from '../cache/response.models'; -import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; -import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; -import { CacheableObject } from '../cache/object-cache.reducer'; import { RequestEntry } from './request.reducer'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; -import { ChangeAnalyzer } from './change-analyzer'; +import { RequestService } from './request.service'; import { RestRequestMethod } from './rest-request-method'; -import { getMapsToType } from '../cache/builders/build-decorators'; import { UpdateDataService } from './update-data.service'; -import { CoreState } from '../core.reducers'; export abstract class DataService implements UpdateDataService { protected abstract requestService: RequestService; protected abstract rdbService: RemoteDataBuildService; - protected abstract dataBuildService: NormalizedObjectBuildService; protected abstract store: Store; protected abstract linkPath: string; protected abstract halService: HALEndpointService; @@ -61,17 +62,19 @@ export abstract class DataService implements UpdateDa protected abstract notificationsService: NotificationsService; protected abstract http: HttpClient; protected abstract comparator: ChangeAnalyzer; + /** * Allows subclasses to reset the response cache time. */ protected responseMsToLive: number; - public abstract getBrowseEndpoint(options: FindListOptions, linkPath?: string): Observable - /** - * Get the base endpoint for all requests + * Get the endpoint for browsing + * @param options The [[FindListOptions]] object + * @param linkPath The link path for the object + * @returns {Observable} */ - protected getEndpoint(): Observable { + getBrowseEndpoint(options: FindListOptions = {}, linkPath?: string): Observable { return this.halService.getEndpoint(this.linkPath); } @@ -82,14 +85,15 @@ export abstract class DataService implements UpdateDa * @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 { - let result: Observable; + protected getFindAllHref(options: FindListOptions = {}, linkPath?: string, ...linksToFollow: Array>): Observable { + let result$: Observable; const args = []; - result = this.getBrowseEndpoint(options, linkPath).pipe(distinctUntilChanged()); + result$ = this.getBrowseEndpoint(options, linkPath).pipe(distinctUntilChanged()); - return this.buildHrefFromFindOptions(result, args, options); + return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); } /** @@ -99,12 +103,13 @@ export abstract class DataService implements UpdateDa * @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 { - let result: Observable; + protected getSearchByHref(searchMethod: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable { + let result$: Observable; const args = []; - result = this.getSearchEndpoint(searchMethod); + result$ = this.getSearchEndpoint(searchMethod); if (hasValue(options.searchParams)) { options.searchParams.forEach((param: SearchParam) => { @@ -112,45 +117,98 @@ export abstract class DataService implements UpdateDa }) } - return this.buildHrefFromFindOptions(result, args, options); + return result$.pipe(map((result: string) => this.buildHrefFromFindOptions(result, options, args, ...linksToFollow))); } /** * Turn an options object into a query string and combine it with the given HREF * - * @param href$ The HREF to which the query string should be appended - * @param args Array with additional params to combine with query string + * @param href The HREF to which the query string should be appended * @param options The [[FindListOptions]] object + * @param extraArgs Array with additional params to combine with query string * @return {Observable} * 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$: Observable, args: string[], options: FindListOptions): Observable { + protected buildHrefFromFindOptions(href: string, options: FindListOptions, extraArgs: string[] = [], ...linksToFollow: Array>): string { + let args = [...extraArgs]; if (hasValue(options.currentPage) && typeof options.currentPage === 'number') { /* TODO: this is a temporary fix for the pagination start index (0 or 1) discrepancy between the rest and the frontend respectively */ - args.push(`page=${options.currentPage - 1}`); + args = [...args, `page=${options.currentPage - 1}`]; } if (hasValue(options.elementsPerPage)) { - args.push(`size=${options.elementsPerPage}`); + args = [...args, `size=${options.elementsPerPage}`]; } if (hasValue(options.sort)) { - args.push(`sort=${options.sort.field},${options.sort.direction}`); + args = [...args, `sort=${options.sort.field},${options.sort.direction}`]; } if (hasValue(options.startsWith)) { - args.push(`startsWith=${options.startsWith}`); + args = [...args, `startsWith=${options.startsWith}`]; } + args = this.addEmbedParams(args, ...linksToFollow); if (isNotEmpty(args)) { - return href$.pipe(map((href: string) => new URLCombiner(href, `?${args.join('&')}`).toString())); + return new URLCombiner(href, `?${args.join('&')}`).toString(); } else { - return href$; + return href; } } - findAll(options: FindListOptions = {}): Observable>> { - return this.findList(this.getFindAllHref(options), options); + /** + * 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; } - protected findList(href$, options: FindListOptions) { + /** + * 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); + } + + /** + * 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>) { href$.pipe( first((href: string) => hasValue(href))) .subscribe((href: string) => { @@ -161,29 +219,37 @@ export abstract class DataService implements UpdateDa this.requestService.configure(request); }); - return this.rdbService.buildList(href$) as Observable>>; + return this.rdbService.buildList(href$, ...linksToFollow) as Observable>>; } /** - * 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); } /** * Create an observable for the HREF of a specific object based on its identifier * @param resourceID The identifier for the object + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ - getIDHrefObs(resourceID: string): Observable { - return this.getEndpoint().pipe( - map((endpoint: string) => this.getIDHref(endpoint, resourceID))); + getIDHrefObs(resourceID: string, ...linksToFollow: Array>): Observable { + return this.getBrowseEndpoint().pipe( + map((endpoint: string) => this.getIDHref(endpoint, resourceID, ...linksToFollow))); } - findById(id: string): Observable> { - const hrefObs = this.getIDHrefObs(encodeURIComponent(id)); + /** + * Returns an observable of {@link RemoteData} of an object, based on its ID, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the object + * @param id ID of object we want to retrieve + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + findById(id: string, ...linksToFollow: Array>): Observable> { + const hrefObs = this.getIDHrefObs(encodeURIComponent(id), ...linksToFollow); hrefObs.pipe( find((href: string) => hasValue(href))) @@ -195,16 +261,40 @@ export abstract class DataService implements UpdateDa this.requestService.configure(request); }); - return this.rdbService.buildSingle(hrefObs); + return this.rdbService.buildSingle(hrefObs, ...linksToFollow); } - findByHref(href: string, options?: HttpOptions): Observable> { - const request = new GetRequest(this.requestService.generateRequestId(), href, null, options); + /** + * Returns an observable of {@link RemoteData} of an object, based on an href, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the object + * @param href The url of object we want to retrieve + * @param 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, {}, [], ...linksToFollow); + const request = new GetRequest(this.requestService.generateRequestId(), requestHref); if (hasValue(this.responseMsToLive)) { request.responseMsToLive = this.responseMsToLive; } this.requestService.configure(request); - return this.rdbService.buildSingle(href); + return this.rdbService.buildSingle(href, ...linksToFollow); + } + + /** + * Returns a list of observables of {@link RemoteData} of objects, based on an href, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the object + * @param href The url of object we want to retrieve + * @param findListOptions Find list options object + * @param 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, [], ...linksToFollow); + const request = new GetRequest(this.requestService.generateRequestId(), requestHref); + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + this.requestService.configure(request); + return this.rdbService.buildList(requestHref, ...linksToFollow); } /** @@ -223,12 +313,13 @@ export abstract class DataService implements UpdateDa * * @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 = {}): 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)), @@ -243,18 +334,34 @@ export abstract class DataService implements UpdateDa switchMap((href) => this.requestService.getByHref(href)), skipWhile((requestEntry) => hasValue(requestEntry) && requestEntry.completed), switchMap((href) => - this.rdbService.buildList(hrefObs) as Observable>> + this.rdbService.buildList(hrefObs, ...linksToFollow) as Observable>> ) ); } /** - * 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) + ); } /** @@ -263,18 +370,18 @@ export abstract class DataService implements UpdateDa * @param {DSpaceObject} object The given object */ update(object: T): Observable> { - const oldVersion$ = this.findByHref(object.self); + const oldVersion$ = this.findByHref(object._links.self.href); return oldVersion$.pipe( getSucceededRemoteData(), getRemoteDataPayload(), mergeMap((oldVersion: T) => { - const operations = this.comparator.diff(oldVersion, object); - if (isNotEmpty(operations)) { - this.objectCache.addPatch(object.self, operations); + const operations = this.comparator.diff(oldVersion, object); + if (isNotEmpty(operations)) { + this.objectCache.addPatch(object._links.self.href, operations); + } + return this.findByHref(object._links.self.href); } - return this.findByHref(object.self); - } - )); + )); } /** @@ -288,14 +395,13 @@ export abstract class DataService implements UpdateDa */ create(dso: T, parentUUID?: string): Observable> { const requestId = this.requestService.generateRequestId(); - const endpoint$ = this.getEndpoint().pipe( + const endpoint$ = this.getBrowseEndpoint().pipe( isNotEmptyOperator(), distinctUntilChanged(), map((endpoint: string) => parentUUID ? `${endpoint}?parent=${parentUUID}` : endpoint) ); - const normalizedObject: NormalizedObject = this.dataBuildService.normalize(dso); - const serializedDso = new DSpaceRESTv2Serializer(getMapsToType((dso as any).type)).serialize(normalizedObject); + const serializedDso = new DSpaceSerializer(getClassForType((dso as any).type)).serialize(dso); const request$ = endpoint$.pipe( take(1), @@ -331,29 +437,110 @@ export abstract class DataService implements UpdateDa } /** - * Delete an existing DSpace Object on the server - * @param dso The DSpace Object to be removed - * Return an observable that emits true when the deletion was successful, false when it failed + * 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 */ - delete(dso: T): Observable { + tryToCreate(dso: T): Observable { const requestId = this.requestService.generateRequestId(); + const endpoint$ = this.halService.getEndpoint(this.linkPath).pipe( + isNotEmptyOperator(), + distinctUntilChanged(), + ); - const hrefObs = this.getIDHrefObs(dso.uuid); + const serializedDso = new DSpaceSerializer(getClassForType((dso as any).type)).serialize(dso); - hrefObs.pipe( - find((href: string) => hasValue(href)), - map((href: string) => { - const request = new DeleteByIDRequest(requestId, href, dso.uuid); - this.requestService.configure(request); - }) + 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 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(dsoID: string, copyVirtualMetadata?: string[]): Observable { + const requestId = this.deleteAndReturnRequestId(dsoID, copyVirtualMetadata); + return this.requestService.getByUUID(requestId).pipe( find((request: RequestEntry) => request.completed), map((request: RequestEntry) => request.response.isSuccessful) ); } + /** + * Delete an existing DSpace Object on the server + * @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 of the completed response + */ + deleteAndReturnResponse(dsoID: string, copyVirtualMetadata?: string[]): Observable { + const requestId = this.deleteAndReturnRequestId(dsoID, copyVirtualMetadata); + + return this.requestService.getByUUID(requestId).pipe( + hasValueOperator(), + find((request: RequestEntry) => request.completed), + map((request: RequestEntry) => request.response) + ); + } + + /** + * Delete an existing DSpace Object on the server + * @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 the delete request's ID + */ + private deleteAndReturnRequestId(dsoID: string, copyVirtualMetadata?: string[]): string { + const requestId = this.requestService.generateRequestId(); + + const hrefObs = this.getIDHrefObs(dsoID); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + if (copyVirtualMetadata) { + copyVirtualMetadata.forEach((id) => + href += (href.includes('?') ? '&' : '?') + + 'copyVirtualMetadata=' + + id + ); + } + const request = new DeleteByIDRequest(requestId, href, dsoID); + this.requestService.configure(request); + }) + ).subscribe(); + + return requestId; + } + /** * Commit current object changes to the server * @param method The RestRequestMethod for which de server sync buffer should be committed diff --git a/src/app/core/data/default-change-analyzer.service.ts b/src/app/core/data/default-change-analyzer.service.ts index 862c0e5b85..20218925fb 100644 --- a/src/app/core/data/default-change-analyzer.service.ts +++ b/src/app/core/data/default-change-analyzer.service.ts @@ -1,10 +1,10 @@ -import { Operation } from 'fast-json-patch/lib/core'; -import { compare } from 'fast-json-patch'; -import { ChangeAnalyzer } from './change-analyzer'; import { Injectable } from '@angular/core'; +import { compare } from 'fast-json-patch'; +import { Operation } from 'fast-json-patch/lib/core'; +import { getClassForType } from '../cache/builders/build-decorators'; import { CacheableObject } from '../cache/object-cache.reducer'; -import { NormalizedObject } from '../cache/models/normalized-object.model'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; +import { ChangeAnalyzer } from './change-analyzer'; /** * A class to determine what differs between two @@ -12,19 +12,18 @@ import { NormalizedObjectBuildService } from '../cache/builders/normalized-objec */ @Injectable() export class DefaultChangeAnalyzer implements ChangeAnalyzer { - constructor(private normalizeService: NormalizedObjectBuildService) { - } - /** * Compare the metadata of two CacheableObject and return the differences as * a JsonPatch Operation Array * - * @param {NormalizedObject} object1 + * @param {CacheableObject} object1 * The first object to compare - * @param {NormalizedObject} object2 + * @param {CacheableObject} object2 * The second object to compare */ - diff(object1: T | NormalizedObject, object2: T | NormalizedObject): Operation[] { - return compare(this.normalizeService.normalize(object1), this.normalizeService.normalize(object2)); + diff(object1: T, object2: T): Operation[] { + const serializer1 = new DSpaceSerializer(getClassForType(object1.type)); + const serializer2 = new DSpaceSerializer(getClassForType(object2.type)); + return compare(serializer1.serialize(object1), serializer2.serialize(object2)); } } diff --git a/src/app/core/data/dso-change-analyzer.service.ts b/src/app/core/data/dso-change-analyzer.service.ts index ce3ed2452e..af0b95234b 100644 --- a/src/app/core/data/dso-change-analyzer.service.ts +++ b/src/app/core/data/dso-change-analyzer.service.ts @@ -1,7 +1,6 @@ import { Operation } from 'fast-json-patch/lib/core'; import { compare } from 'fast-json-patch'; import { ChangeAnalyzer } from './change-analyzer'; -import { NormalizedDSpaceObject } from '../cache/models/normalized-dspace-object.model'; import { Injectable } from '@angular/core'; import { DSpaceObject } from '../shared/dspace-object.model'; import { MetadataMap } from '../shared/metadata.models'; @@ -18,12 +17,12 @@ export class DSOChangeAnalyzer implements ChangeAnalyzer * Compare the metadata of two DSpaceObjects and return the differences as * a JsonPatch Operation Array * - * @param {NormalizedDSpaceObject} object1 + * @param {DSpaceObject} object1 * The first object to compare - * @param {NormalizedDSpaceObject} object2 + * @param {DSpaceObject} object2 * The second object to compare */ - diff(object1: T | NormalizedDSpaceObject, object2: T | NormalizedDSpaceObject): Operation[] { + diff(object1: DSpaceObject, object2: DSpaceObject): Operation[] { return compare(this.filterUUIDsFromMetadata(object1.metadata), this.filterUUIDsFromMetadata(object2.metadata)) .map((operation: Operation) => Object.assign({}, operation, { path: '/metadata' + operation.path })); } 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 80507c4492..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,16 +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 { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; -import { DsoRedirectDataService } from './dso-redirect-data.service'; -import { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; describe('DsoRedirectDataService', () => { let scheduler: TestScheduler; @@ -31,7 +33,6 @@ describe('DsoRedirectDataService', () => { const notificationsService = {} as NotificationsService; const http = {} as HttpClient; const comparator = {} as any; - const dataBuildService = {} as NormalizedObjectBuildService; const objectCache = {} as ObjectCacheService; let setup; beforeEach(() => { @@ -68,7 +69,6 @@ describe('DsoRedirectDataService', () => { service = new DsoRedirectDataService( requestService, rdbService, - dataBuildService, store, objectCache, halService, @@ -83,7 +83,7 @@ describe('DsoRedirectDataService', () => { describe('findById', () => { it('should call HALEndpointService with the path to the pid endpoint', () => { setup(); - scheduler.schedule(() => service.findById(dsoHandle, IdentifierType.HANDLE)); + scheduler.schedule(() => service.findByIdAndIDType(dsoHandle, IdentifierType.HANDLE)); scheduler.flush(); expect(halService.getEndpoint).toHaveBeenCalledWith('pid'); @@ -91,7 +91,7 @@ describe('DsoRedirectDataService', () => { it('should call HALEndpointService with the path to the dso endpoint', () => { setup(); - scheduler.schedule(() => service.findById(dsoUUID, IdentifierType.UUID)); + scheduler.schedule(() => service.findByIdAndIDType(dsoUUID, IdentifierType.UUID)); scheduler.flush(); expect(halService.getEndpoint).toHaveBeenCalledWith('dso'); @@ -99,7 +99,7 @@ describe('DsoRedirectDataService', () => { it('should call HALEndpointService with the path to the dso endpoint when identifier type not specified', () => { setup(); - scheduler.schedule(() => service.findById(dsoUUID)); + scheduler.schedule(() => service.findByIdAndIDType(dsoUUID)); scheduler.flush(); expect(halService.getEndpoint).toHaveBeenCalledWith('dso'); @@ -107,7 +107,7 @@ describe('DsoRedirectDataService', () => { it('should configure the proper FindByIDRequest for uuid', () => { setup(); - scheduler.schedule(() => service.findById(dsoUUID, IdentifierType.UUID)); + scheduler.schedule(() => service.findByIdAndIDType(dsoUUID, IdentifierType.UUID)); scheduler.flush(); expect(requestService.configure).toHaveBeenCalledWith(new FindByIDRequest(requestUUID, requestUUIDURL, dsoUUID)); @@ -115,7 +115,7 @@ describe('DsoRedirectDataService', () => { it('should configure the proper FindByIDRequest for handle', () => { setup(); - scheduler.schedule(() => service.findById(dsoHandle, IdentifierType.HANDLE)); + scheduler.schedule(() => service.findByIdAndIDType(dsoHandle, IdentifierType.HANDLE)); scheduler.flush(); expect(requestService.configure).toHaveBeenCalledWith(new FindByIDRequest(requestUUID, requestHandleURL, dsoHandle)); @@ -124,7 +124,7 @@ describe('DsoRedirectDataService', () => { it('should navigate to item route', () => { remoteData.payload.type = 'item'; setup(); - const redir = service.findById(dsoHandle, IdentifierType.HANDLE); + const redir = service.findByIdAndIDType(dsoHandle, IdentifierType.HANDLE); // The framework would normally subscribe but do it here so we can test navigation. redir.subscribe(); scheduler.schedule(() => redir); @@ -135,7 +135,7 @@ describe('DsoRedirectDataService', () => { it('should navigate to collections route', () => { remoteData.payload.type = 'collection'; setup(); - const redir = service.findById(dsoHandle, IdentifierType.HANDLE); + const redir = service.findByIdAndIDType(dsoHandle, IdentifierType.HANDLE); redir.subscribe(); scheduler.schedule(() => redir); scheduler.flush(); @@ -145,11 +145,77 @@ describe('DsoRedirectDataService', () => { it('should navigate to communities route', () => { remoteData.payload.type = 'community'; setup(); - const redir = service.findById(dsoHandle, IdentifierType.HANDLE); + const redir = service.findByIdAndIDType(dsoHandle, IdentifierType.HANDLE); redir.subscribe(); scheduler.schedule(() => redir); 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 f4999637b3..87259a4279 100644 --- a/src/app/core/data/dso-redirect-data.service.ts +++ b/src/app/core/data/dso-redirect-data.service.ts @@ -1,35 +1,33 @@ -import { DataService } from './data.service'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HttpClient } from '@angular/common/http'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { RequestService } from './request.service'; -import { Store } from '@ngrx/store'; -import { CoreState } from '../core.reducers'; -import { FindListOptions, FindByIDRequest, IdentifierType } from './request.models'; -import { Observable } from 'rxjs'; -import { RemoteData } from './remote-data'; -import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { Injectable } from '@angular/core'; -import { filter, take, tap } from 'rxjs/operators'; -import { hasValue } from '../../shared/empty.util'; -import { getFinishedRemoteData } from '../shared/operators'; import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +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'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { getFinishedRemoteData } from '../shared/operators'; +import { DataService } from './data.service'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { RemoteData } from './remote-data'; +import { FindByIDRequest, IdentifierType } from './request.models'; +import { RequestService } from './request.service'; @Injectable() export class DsoRedirectDataService extends DataService { // Set the default link path to the identifier lookup endpoint. protected linkPath = 'pid'; - protected forceBypassCache = false; private uuidEndpoint = 'dso'; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, @@ -40,10 +38,6 @@ export class DsoRedirectDataService extends DataService { super(); } - getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { - return this.halService.getEndpoint(linkPath); - } - setLinkPath(identifierType: IdentifierType) { // The default 'pid' endpoint for identifiers does not support uuid lookups. // For uuid lookups we need to change the linkPath. @@ -52,15 +46,16 @@ 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); } - findById(id: string, identifierType = IdentifierType.UUID): Observable> { + findByIdAndIDType(id: string, identifierType = IdentifierType.UUID): Observable> { this.setLinkPath(identifierType); - return super.findById(id).pipe( + return this.findById(id).pipe( getFinishedRemoteData(), take(1), tap((response) => { diff --git a/src/app/core/data/dso-response-parsing.service.ts b/src/app/core/data/dso-response-parsing.service.ts index d2c21825cc..83676ce105 100644 --- a/src/app/core/data/dso-response-parsing.service.ts +++ b/src/app/core/data/dso-response-parsing.service.ts @@ -3,7 +3,6 @@ import { Inject, Injectable } from '@angular/core'; import { ObjectCacheService } from '../cache/object-cache.service'; import { GlobalConfig } from '../../../config/global-config.interface'; import { GLOBAL_CONFIG } from '../../../config'; -import { NormalizedObject } from '../cache/models/normalized-object.model'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { RestResponse, DSOSuccessResponse } from '../cache/response.models'; import { RestRequest } from './request.models'; @@ -30,7 +29,7 @@ export class DSOResponseParsingService extends BaseResponseParsingService implem if (hasValue(data.payload) && hasValue(data.payload.page) && data.payload.page.totalElements === 0) { processRequestDTO = { page: [] }; } else { - processRequestDTO = this.process>(data.payload, request); + processRequestDTO = this.process(data.payload, request); } let objectList = processRequestDTO; @@ -42,7 +41,7 @@ export class DSOResponseParsingService extends BaseResponseParsingService implem } else if (!Array.isArray(processRequestDTO)) { objectList = [processRequestDTO]; } - const selfLinks = objectList.map((no) => no.self); + const selfLinks = objectList.map((no) => no._links.self.href); return new DSOSuccessResponse(selfLinks, data.statusCode, data.statusText, this.processPageInfo(data.payload)) } diff --git a/src/app/core/data/dspace-object-data.service.spec.ts b/src/app/core/data/dspace-object-data.service.spec.ts index 7047db6065..b7c8c3fe9d 100644 --- a/src/app/core/data/dspace-object-data.service.spec.ts +++ b/src/app/core/data/dspace-object-data.service.spec.ts @@ -9,8 +9,6 @@ import { DSpaceObjectDataService } from './dspace-object-data.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; - describe('DSpaceObjectDataService', () => { let scheduler: TestScheduler; let service: DSpaceObjectDataService; @@ -46,12 +44,10 @@ describe('DSpaceObjectDataService', () => { const notificationsService = {} as NotificationsService; const http = {} as HttpClient; const comparator = {} as any; - const dataBuildService = {} as NormalizedObjectBuildService; service = new DSpaceObjectDataService( requestService, rdbService, - dataBuildService, objectCache, halService, notificationsService, diff --git a/src/app/core/data/dspace-object-data.service.ts b/src/app/core/data/dspace-object-data.service.ts index 002ac3cdbc..61cc98281e 100644 --- a/src/app/core/data/dspace-object-data.service.ts +++ b/src/app/core/data/dspace-object-data.service.ts @@ -1,19 +1,20 @@ +import { HttpClient } from '@angular/common/http'; 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'; import { CoreState } from '../core.reducers'; import { DSpaceObject } from '../shared/dspace-object.model'; +import { DSPACE_OBJECT } from '../shared/dspace-object.resource-type'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { DataService } from './data.service'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { RemoteData } from './remote-data'; import { RequestService } from './request.service'; -import { FindListOptions } from './request.models'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; -import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; /* tslint:disable:max-classes-per-file */ class DataServiceImpl extends DataService { @@ -22,7 +23,6 @@ class DataServiceImpl extends DataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, @@ -32,16 +32,14 @@ class DataServiceImpl extends DataService { super(); } - getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { - return this.halService.getEndpoint(linkPath); - } - - 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); } } @Injectable() +@dataService(DSPACE_OBJECT) export class DSpaceObjectDataService { protected linkPath = 'dso'; private dataService: DataServiceImpl; @@ -49,13 +47,12 @@ export class DSpaceObjectDataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected dataBuildService: NormalizedObjectBuildService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, protected notificationsService: NotificationsService, protected http: HttpClient, protected comparator: DSOChangeAnalyzer) { - this.dataService = new DataServiceImpl(requestService, rdbService, dataBuildService, null, objectCache, halService, notificationsService, http, comparator); + this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); } findById(uuid: string): Observable> { diff --git a/src/app/core/data/entity-type-data.service.ts b/src/app/core/data/entity-type-data.service.ts new file mode 100644 index 0000000000..87de69b935 --- /dev/null +++ b/src/app/core/data/entity-type-data.service.ts @@ -0,0 +1,85 @@ +import { HttpClient } from '@angular/common/http'; +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'; +import { CoreState } from '../core.reducers'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ItemType } from '../shared/item-relationships/item-type.model'; +import { ITEM_TYPE } from '../shared/item-relationships/item-type.resource-type'; +import { DataService } from './data.service'; +import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { PaginatedList } from './paginated-list'; +import { RemoteData } from './remote-data'; +import { FindListOptions } from './request.models'; +import { RequestService } from './request.service'; + +/* tslint:disable:max-classes-per-file */ + +/** + * A private DataService implementation to delegate specific methods to. + */ +class DataServiceImpl extends DataService { + protected linkPath = 'entitytypes'; + + 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(); + } +} + +/** + * A service to retrieve {@link ItemType}s from the REST API. + */ +@Injectable() +@dataService(ITEM_TYPE) +export class ItemTypeDataService { + /** + * A private DataService instance to delegate specific methods to. + */ + private dataService: DataServiceImpl; + + 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) { + this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); + } + + /** + * Returns an observable of {@link RemoteData} of an {@link ItemType}, based on an href, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the {@link ItemType} + * @param href The url of {@link ItemType} we want to retrieve + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + findByHref(href: string, ...linksToFollow: Array>): Observable> { + return this.dataService.findByHref(href, ...linksToFollow); + } + + /** + * Returns a list of observables of {@link RemoteData} of {@link ItemType}s, based on an href, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the {@link ItemType} + * @param href The url of the {@link ItemType} we want to retrieve + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + findByAllHref(href: string, findListOptions: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { + return this.dataService.findAllByHref(href, findListOptions, ...linksToFollow); + } +} +/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/data/entity-type.service.ts b/src/app/core/data/entity-type.service.ts new file mode 100644 index 0000000000..b8e8b7cd9a --- /dev/null +++ b/src/app/core/data/entity-type.service.ts @@ -0,0 +1,102 @@ +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { DataService } from './data.service'; +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 { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { HttpClient } from '@angular/common/http'; +import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { Injectable } from '@angular/core'; +import { GetRequest } from './request.models'; +import { Observable } from 'rxjs/internal/Observable'; +import {switchMap, take, tap} from 'rxjs/operators'; +import { RemoteData } from './remote-data'; +import {RelationshipType} from '../shared/item-relationships/relationship-type.model'; +import {PaginatedList} from './paginated-list'; +import {ItemType} from '../shared/item-relationships/item-type.model'; + +/** + * Service handling all ItemType requests + */ +@Injectable() +export class EntityTypeService extends DataService { + + protected linkPath = 'entitytypes'; + + constructor(protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected halService: HALEndpointService, + protected objectCache: ObjectCacheService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer) { + super(); + } + + getBrowseEndpoint(options, linkPath?: string): Observable { + return this.halService.getEndpoint(this.linkPath); + } + + /** + * Get the endpoint for the item type's allowed relationship types + * @param entityTypeId + */ + getRelationshipTypesEndpoint(entityTypeId: string): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + switchMap((href) => this.halService.getEndpoint('relationshiptypes', `${href}/${entityTypeId}`)) + ); + } + + /** + * Get the allowed relationship types for an entity type + * @param entityTypeId + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + getEntityTypeRelationships(entityTypeId: string, ...linksToFollow: Array>): Observable>> { + + const href$ = this.getRelationshipTypesEndpoint(entityTypeId); + + href$.pipe(take(1)).subscribe((href) => { + const request = new GetRequest(this.requestService.generateRequestId(), href); + this.requestService.configure(request); + }); + + return this.rdbService.buildList(href$, ...linksToFollow); + } + + /** + * Get an entity type by their label + * @param label + */ + getEntityTypeByLabel(label: string): Observable> { + + // TODO: Remove mock data once REST API supports this + /* + href$.pipe(take(1)).subscribe((href) => { + const request = new GetRequest(this.requestService.generateRequestId(), href); + this.requestService.configure(request); + }); + + return this.rdbService.buildSingle(href$); + */ + + // Mock: + const index = [ + 'Publication', + 'Person', + 'Project', + 'OrgUnit', + 'Journal', + 'JournalVolume', + 'JournalIssue', + 'DataPackage', + 'DataFile', + ].indexOf(label); + + return this.findById((index + 1) + ''); + } +} diff --git a/src/app/core/data/external-source.service.spec.ts b/src/app/core/data/external-source.service.spec.ts index 77a2a85dfd..f891b46883 100644 --- a/src/app/core/data/external-source.service.spec.ts +++ b/src/app/core/data/external-source.service.spec.ts @@ -49,7 +49,7 @@ describe('ExternalSourceService', () => { halService = jasmine.createSpyObj('halService', { getEndpoint: observableOf('external-sources-REST-endpoint') }); - service = new ExternalSourceService(requestService, rdbService, undefined, undefined, undefined, halService, undefined, undefined, undefined); + service = new ExternalSourceService(requestService, rdbService, undefined, undefined, halService, undefined, undefined, undefined); } beforeEach(() => { diff --git a/src/app/core/data/external-source.service.ts b/src/app/core/data/external-source.service.ts index c32c13a20f..0c1a8d255c 100644 --- a/src/app/core/data/external-source.service.ts +++ b/src/app/core/data/external-source.service.ts @@ -3,7 +3,6 @@ import { DataService } from './data.service'; import { ExternalSource } from '../shared/external-source.model'; import { RequestService } from './request.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { Store } from '@ngrx/store'; import { CoreState } from '../core.reducers'; import { ObjectCacheService } from '../cache/object-cache.service'; @@ -31,7 +30,6 @@ export class ExternalSourceService extends DataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, diff --git a/src/app/core/data/facet-config-response-parsing.service.ts b/src/app/core/data/facet-config-response-parsing.service.ts index 19b37f8b5d..3fc14b6495 100644 --- a/src/app/core/data/facet-config-response-parsing.service.ts +++ b/src/app/core/data/facet-config-response-parsing.service.ts @@ -1,17 +1,14 @@ import { Inject, Injectable } from '@angular/core'; -import { - FacetConfigSuccessResponse, - RestResponse -} from '../cache/response.models'; +import { GLOBAL_CONFIG } from '../../../config'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { SearchFilterConfig } from '../../shared/search/search-filter-config.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { FacetConfigSuccessResponse, RestResponse } from '../cache/response.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; +import { BaseResponseParsingService } from './base-response-parsing.service'; import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; -import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; -import { SearchFilterConfig } from '../../shared/search/search-filter-config.model'; -import { BaseResponseParsingService } from './base-response-parsing.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { GlobalConfig } from '../../../config/global-config.interface'; -import { GLOBAL_CONFIG } from '../../../config'; @Injectable() export class FacetConfigResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { @@ -24,7 +21,7 @@ export class FacetConfigResponseParsingService extends BaseResponseParsingServic parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { const config = data.payload._embedded.facets; - const serializer = new DSpaceRESTv2Serializer(SearchFilterConfig); + const serializer = new DSpaceSerializer(SearchFilterConfig); const facetConfig = serializer.deserializeArray(config); return new FacetConfigSuccessResponse(facetConfig, data.statusCode, data.statusText); } diff --git a/src/app/core/data/facet-value-map-response-parsing.service.ts b/src/app/core/data/facet-value-map-response-parsing.service.ts index 64c8e87e7d..8c8c12dff7 100644 --- a/src/app/core/data/facet-value-map-response-parsing.service.ts +++ b/src/app/core/data/facet-value-map-response-parsing.service.ts @@ -1,19 +1,19 @@ import { Inject, Injectable } from '@angular/core'; +import { GLOBAL_CONFIG } from '../../../config'; +import { GlobalConfig } from '../../../config/global-config.interface'; +import { FacetValue } from '../../shared/search/facet-value.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; import { FacetValueMap, FacetValueMapSuccessResponse, FacetValueSuccessResponse, RestResponse } from '../cache/response.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; +import { BaseResponseParsingService } from './base-response-parsing.service'; import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; -import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; -import { FacetValue } from '../../shared/search/facet-value.model'; -import { BaseResponseParsingService } from './base-response-parsing.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { GlobalConfig } from '../../../config/global-config.interface'; -import { GLOBAL_CONFIG } from '../../../config'; @Injectable() export class FacetValueMapResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { @@ -30,7 +30,7 @@ export class FacetValueMapResponseParsingService extends BaseResponseParsingServ const payload = data.payload; const facetMap: FacetValueMap = new FacetValueMap(); - const serializer = new DSpaceRESTv2Serializer(FacetValue); + const serializer = new DSpaceSerializer(FacetValue); payload._embedded.facets.map((facet) => { const values = facet._embedded.values.map((value) => {value.search = value._links.search.href; return value;}); const facetValues = serializer.deserializeArray(values); diff --git a/src/app/core/data/facet-value-response-parsing.service.ts b/src/app/core/data/facet-value-response-parsing.service.ts index 70585bc3d9..c9ff93a1ae 100644 --- a/src/app/core/data/facet-value-response-parsing.service.ts +++ b/src/app/core/data/facet-value-response-parsing.service.ts @@ -1,14 +1,14 @@ import { Inject, Injectable } from '@angular/core'; -import { FacetValueSuccessResponse, RestResponse } from '../cache/response.models'; -import { ResponseParsingService } from './parsing.service'; -import { RestRequest } from './request.models'; -import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; -import { FacetValue } from '../../shared/search/facet-value.model'; -import { BaseResponseParsingService } from './base-response-parsing.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; import { GLOBAL_CONFIG } from '../../../config'; import { GlobalConfig } from '../../../config/global-config.interface'; +import { FacetValue } from '../../shared/search/facet-value.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { FacetValueSuccessResponse, RestResponse } from '../cache/response.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; +import { BaseResponseParsingService } from './base-response-parsing.service'; +import { ResponseParsingService } from './parsing.service'; +import { RestRequest } from './request.models'; @Injectable() export class FacetValueResponseParsingService extends BaseResponseParsingService implements ResponseParsingService { @@ -21,7 +21,7 @@ export class FacetValueResponseParsingService extends BaseResponseParsingService parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { const payload = data.payload; - const serializer = new DSpaceRESTv2Serializer(FacetValue); + const serializer = new DSpaceSerializer(FacetValue); // const values = payload._embedded.values.map((value) => {value.search = value._links.search.href; return value;}); const facetValues = serializer.deserializeArray(payload._embedded.values); diff --git a/src/app/core/data/item-data.service.spec.ts b/src/app/core/data/item-data.service.spec.ts index 6f2719f374..2519c90973 100644 --- a/src/app/core/data/item-data.service.spec.ts +++ b/src/app/core/data/item-data.service.spec.ts @@ -1,20 +1,20 @@ +import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; import { cold, getTestScheduler } from 'jasmine-marbles'; -import { TestScheduler } from 'rxjs/testing'; -import { BrowseService } from '../browse/browse.service'; -import { CoreState } from '../core.reducers'; -import { ItemDataService } from './item-data.service'; -import { RequestService } from './request.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { DeleteRequest, FindListOptions, PostRequest, RestRequest } from './request.models'; -import { ObjectCacheService } from '../cache/object-cache.service'; import { Observable, of as observableOf } from 'rxjs'; -import { RestResponse } from '../cache/response.models'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; -import { HttpClient } from '@angular/common/http'; -import { RequestEntry } from './request.reducer'; +import { TestScheduler } from 'rxjs/testing'; import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { BrowseService } from '../browse/browse.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { RestResponse } from '../cache/response.models'; +import { CoreState } from '../core.reducers'; +import { ExternalSourceEntry } from '../shared/external-source-entry.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ItemDataService } from './item-data.service'; +import { DeleteRequest, FindListOptions, PostRequest, RestRequest } from './request.models'; +import { RequestEntry } from './request.reducer'; +import { RequestService } from './request.service'; describe('ItemDataService', () => { let scheduler: TestScheduler; @@ -44,9 +44,12 @@ describe('ItemDataService', () => { const objectCache = {} as ObjectCacheService; const halEndpointService = { getEndpoint(linkPath: string): Observable { - return cold('a', {a: itemEndpoint}); + return cold('a', { a: itemEndpoint }); } } as HALEndpointService; + const bundleService = jasmine.createSpyObj('bundleService', { + findByHref: {} + }); const scopeID = '4af28e99-6a9c-4036-a199-e1b587046d39'; const options = Object.assign(new FindListOptions(), { @@ -65,7 +68,6 @@ describe('ItemDataService', () => { const notificationsService = {} as NotificationsService; const http = {} as HttpClient; const comparator = {} as any; - const dataBuildService = {} as NormalizedObjectBuildService; const itemEndpoint = 'https://rest.api/core/items'; const ScopedItemEndpoint = `https://rest.api/core/items/${scopeID}`; @@ -82,14 +84,14 @@ describe('ItemDataService', () => { return new ItemDataService( requestService, rdbService, - dataBuildService, store, bs, objectCache, halEndpointService, notificationsService, http, - comparator + comparator, + bundleService ); } @@ -131,7 +133,7 @@ describe('ItemDataService', () => { it('should return the endpoint to withdraw and reinstate items', () => { const result = service.getItemWithdrawEndpoint(scopeID); - const expected = cold('a', {a: ScopedItemEndpoint}); + const expected = cold('a', { a: ScopedItemEndpoint }); expect(result).toBeObservable(expected); }); @@ -153,7 +155,7 @@ describe('ItemDataService', () => { it('should return the endpoint to make an item private or public', () => { const result = service.getItemDiscoverableEndpoint(scopeID); - const expected = cold('a', {a: ScopedItemEndpoint}); + const expected = cold('a', { a: ScopedItemEndpoint }); expect(result).toBeObservable(expected); }); @@ -194,4 +196,40 @@ describe('ItemDataService', () => { }); }); + describe('importExternalSourceEntry', () => { + let result; + + const externalSourceEntry = Object.assign(new ExternalSourceEntry(), { + display: 'John, Doe', + value: 'John, Doe', + _links: { self: { href: 'http://test-rest.com/server/api/integration/externalSources/orcidV2/entryValues/0000-0003-4851-8004' } } + }); + + beforeEach(() => { + service = initTestService(); + spyOn(requestService, 'configure'); + result = service.importExternalSourceEntry(externalSourceEntry, 'collection-id'); + }); + + it('should configure a POST request', () => { + result.subscribe(() => expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(PostRequest))); + }); + }); + + describe('createBundle', () => { + const itemId = '3de6ea60-ec39-419b-ae6f-065930ac1429'; + const bundleName = 'ORIGINAL'; + let result; + + beforeEach(() => { + service = initTestService(); + spyOn(requestService, 'configure'); + result = service.createBundle(itemId, bundleName); + }); + + it('should configure a POST request', () => { + result.subscribe(() => expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(PostRequest))); + }); + }); + }); diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index d4c7287d36..218abb2dee 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -1,58 +1,68 @@ -import { distinctUntilChanged, filter, find, map, switchMap, take } from 'rxjs/operators'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; +import { distinctUntilChanged, filter, find, map, switchMap, take } from 'rxjs/operators'; import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; -import { BrowseService } from '../browse/browse.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { CoreState } from '../core.reducers'; -import { Item } from '../shared/item.model'; -import { URLCombiner } from '../url-combiner/url-combiner'; - -import { DataService } from './data.service'; -import { RequestService } from './request.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { - DeleteRequest, - FindListOptions, - MappedCollectionsRequest, - PatchRequest, - PostRequest, PutRequest, - RestRequest -} from './request.models'; -import { ObjectCacheService } from '../cache/object-cache.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { HttpClient, HttpHeaders } from '@angular/common/http'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { BrowseService } from '../browse/browse.service'; +import { dataService } from '../cache/builders/build-decorators'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { GenericSuccessResponse, RestResponse } from '../cache/response.models'; +import { CoreState } from '../core.reducers'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { Collection } from '../shared/collection.model'; +import { ExternalSourceEntry } from '../shared/external-source-entry.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { Item } from '../shared/item.model'; +import { ITEM } from '../shared/item.resource-type'; import { configureRequest, filterSuccessfulResponses, - getRequestFromRequestHref, + getRequestFromRequestHref, getRequestFromRequestUUID, getResponseFromEntry } from '../shared/operators'; -import { RequestEntry } from './request.reducer'; -import { GenericSuccessResponse, RestResponse } from '../cache/response.models'; -import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; -import { Collection } from '../shared/collection.model'; -import { RemoteData } from './remote-data'; +import { URLCombiner } from '../url-combiner/url-combiner'; + +import { DataService } from './data.service'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; import { PaginatedList } from './paginated-list'; +import { RemoteData } from './remote-data'; +import { + DeleteRequest, + FindListOptions, + GetRequest, + MappedCollectionsRequest, + PatchRequest, + PostRequest, + PutRequest, + RestRequest +} from './request.models'; +import { RequestEntry } from './request.reducer'; +import { RequestService } from './request.service'; +import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model'; +import { Bundle } from '../shared/bundle.model'; +import { MetadataMap } from '../shared/metadata.models'; +import { BundleDataService } from './bundle-data.service'; @Injectable() +@dataService(ITEM) export class ItemDataService extends DataService { protected linkPath = 'items'; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected bs: BrowseService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, protected notificationsService: NotificationsService, protected http: HttpClient, - protected comparator: DSOChangeAnalyzer) { + protected comparator: DSOChangeAnalyzer, + protected bundleService: BundleDataService + ) { super(); } @@ -176,14 +186,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) ); } @@ -197,18 +210,91 @@ 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) ); } + /** + * Get the endpoint for an item's bundles + * @param itemId + */ + public getBundlesEndpoint(itemId: string): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + switchMap((url: string) => this.halService.getEndpoint('bundles', `${url}/${itemId}`)) + ); + } + + /** + * Get an item's bundles using paginated search options + * @param itemId The item's ID + * @param searchOptions The search options to use + */ + public getBundles(itemId: string, searchOptions?: PaginatedSearchOptions): Observable>> { + const hrefObs = this.getBundlesEndpoint(itemId).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); + } + + /** + * Create a new bundle on an item + * @param itemId The item's ID + * @param bundleName The new bundle's name + * @param metadata Optional metadata for the bundle + */ + public createBundle(itemId: string, bundleName: string, metadata?: MetadataMap): Observable> { + const requestId = this.requestService.generateRequestId(); + const hrefObs = this.getBundlesEndpoint(itemId); + + const bundleJson = { + name: bundleName, + metadata: metadata ? metadata : {} + }; + + hrefObs.pipe( + take(1) + ).subscribe((href) => { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'application/json'); + options.headers = headers; + const request = new PostRequest(requestId, href, JSON.stringify(bundleJson), options); + this.requestService.configure(request); + }); + + const selfLink$ = this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry(), + map((response: any) => { + if (isNotEmpty(response.resourceSelfLinks)) { + return response.resourceSelfLinks[0]; + } + }), + distinctUntilChanged() + ) as Observable; + + return selfLink$.pipe( + switchMap((selfLink: string) => this.bundleService.findByHref(selfLink)), + ); + } + /** * Get the endpoint to move the item * @param itemId @@ -237,7 +323,7 @@ export class ItemDataService extends DataService { hrefObs.pipe( find((href: string) => hasValue(href)), map((href: string) => { - const request = new PutRequest(requestId, href, collection.self, options); + const request = new PutRequest(requestId, href, collection._links.self.href, options); this.requestService.configure(request); }) ).subscribe(); @@ -248,6 +334,40 @@ export class ItemDataService extends DataService { ); } + /** + * Import an external source entry into a collection + * @param externalSourceEntry + * @param collectionId + */ + public importExternalSourceEntry(externalSourceEntry: ExternalSourceEntry, collectionId: string): Observable> { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + + const requestId = this.requestService.generateRequestId(); + const href$ = this.halService.getEndpoint(this.linkPath).pipe(map((href) => `${href}?owningCollection=${collectionId}`)); + + href$.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new PostRequest(requestId, href, externalSourceEntry._links.self.href, options); + this.requestService.configure(request); + }) + ).subscribe(); + + return this.requestService.getByUUID(requestId).pipe( + find((request: RequestEntry) => request.completed), + getResponseFromEntry(), + map((response: any) => { + if (isNotEmpty(response.resourceSelfLinks)) { + return response.resourceSelfLinks[0]; + } + }), + switchMap((selfLink: string) => this.findByHref(selfLink)) + ); + } + /** * Get the endpoint for an item's bitstreams * @param itemId diff --git a/src/app/core/data/license-data.service.ts b/src/app/core/data/license-data.service.ts new file mode 100644 index 0000000000..23637be596 --- /dev/null +++ b/src/app/core/data/license-data.service.ts @@ -0,0 +1,85 @@ +import { HttpClient } from '@angular/common/http'; +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'; +import { CoreState } from '../core.reducers'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { License } from '../shared/license.model'; +import { LICENSE } from '../shared/license.resource-type'; +import { DataService } from './data.service'; +import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { PaginatedList } from './paginated-list'; +import { RemoteData } from './remote-data'; +import { FindListOptions } from './request.models'; +import { RequestService } from './request.service'; + +/* tslint:disable:max-classes-per-file */ + +/** + * A private DataService implementation to delegate specific methods to. + */ +class DataServiceImpl extends DataService { + protected linkPath = ''; + + 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(); + } +} + +/** + * A service to retrieve {@link License}s from the REST API. + */ +@Injectable() +@dataService(LICENSE) +export class LicenseDataService { + /** + * A private DataService instance to delegate specific methods to. + */ + private dataService: DataServiceImpl; + + 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) { + this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); + } + + /** + * Returns an observable of {@link RemoteData} of a {@link License}, based on an href, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the {@link License} + * @param href The URL of object we want to retrieve + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + findByHref(href: string, ...linksToFollow: Array>): Observable> { + return this.dataService.findByHref(href, ...linksToFollow); + } + + /** + * Returns a list of observables of {@link RemoteData} of {@link License}s, based on an href, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the {@link License} + * @param href The URL of object we want to retrieve + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + findByAllHref(href: string, findListOptions: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { + return this.dataService.findAllByHref(href, findListOptions, ...linksToFollow); + } +} +/* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/data/lookup-relation.service.spec.ts b/src/app/core/data/lookup-relation.service.spec.ts index 321fd8d218..c9fc7fc50d 100644 --- a/src/app/core/data/lookup-relation.service.spec.ts +++ b/src/app/core/data/lookup-relation.service.spec.ts @@ -10,11 +10,14 @@ import { SearchResult } from '../../shared/search/search-result.model'; import { Item } from '../shared/item.model'; import { skip, take } from 'rxjs/operators'; import { ExternalSource } from '../shared/external-source.model'; +import { RequestService } from './request.service'; +import { of as observableOf } from 'rxjs'; describe('LookupRelationService', () => { let service: LookupRelationService; let externalSourceService: ExternalSourceService; let searchService: SearchService; + let requestService: RequestService; const totalExternal = 8; const optionsWithQuery = new PaginatedSearchOptions({ query: 'test-query' }); @@ -35,15 +38,18 @@ describe('LookupRelationService', () => { name: 'orcidV2', hierarchical: false }); + const searchServiceEndpoint = 'http://test-rest.com/server/api/core/search'; function init() { externalSourceService = jasmine.createSpyObj('externalSourceService', { getExternalSourceEntries: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo({ elementsPerPage: 1, totalElements: totalExternal, totalPages: totalExternal, currentPage: 1 }), [{}])) }); searchService = jasmine.createSpyObj('searchService', { - search: createSuccessfulRemoteDataObject$(createPaginatedList(localResults)) + search: createSuccessfulRemoteDataObject$(createPaginatedList(localResults)), + getEndpoint: observableOf(searchServiceEndpoint) }); - service = new LookupRelationService(externalSourceService, searchService); + requestService = jasmine.createSpyObj('requestService', ['removeByHrefSubstring']); + service = new LookupRelationService(externalSourceService, searchService, requestService); } beforeEach(() => { @@ -113,4 +119,14 @@ describe('LookupRelationService', () => { }); }); }); + + describe('removeLocalResultsCache', () => { + beforeEach(() => { + service.removeLocalResultsCache(); + }); + + it('should call requestService\'s removeByHrefSubstring with the search endpoint', () => { + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(searchServiceEndpoint); + }); + }); }); diff --git a/src/app/core/data/lookup-relation.service.ts b/src/app/core/data/lookup-relation.service.ts index ad977e42dc..395976cbc3 100644 --- a/src/app/core/data/lookup-relation.service.ts +++ b/src/app/core/data/lookup-relation.service.ts @@ -15,6 +15,7 @@ import { getAllSucceededRemoteData, getRemoteDataPayload } from '../shared/opera import { Injectable } from '@angular/core'; import { ExternalSource } from '../shared/external-source.model'; import { ExternalSourceEntry } from '../shared/external-source-entry.model'; +import { RequestService } from './request.service'; /** * A service for retrieving local and external entries information during a relation lookup @@ -35,7 +36,8 @@ export class LookupRelationService { }); constructor(protected externalSourceService: ExternalSourceService, - protected searchService: SearchService) { + protected searchService: SearchService, + protected requestService: RequestService) { } /** @@ -91,4 +93,11 @@ export class LookupRelationService { startWith(0) ); } + + /** + * Remove cached requests from local results + */ + removeLocalResultsCache() { + this.searchService.getEndpoint().subscribe((href) => this.requestService.removeByHrefSubstring(href)); + } } diff --git a/src/app/core/data/metadata-schema-data.service.ts b/src/app/core/data/metadata-schema-data.service.ts index 662eaa6c7c..915f588379 100644 --- a/src/app/core/data/metadata-schema-data.service.ts +++ b/src/app/core/data/metadata-schema-data.service.ts @@ -1,20 +1,19 @@ +import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; -import { Observable } from 'rxjs'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; import { CoreState } from '../core.reducers'; +import { MetadataSchema } from '../metadata/metadata-schema.model'; +import { METADATA_SCHEMA } from '../metadata/metadata-schema.resource-type'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ChangeAnalyzer } from './change-analyzer'; import { DataService } from './data.service'; -import { RequestService } from './request.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { FindListOptions } from './request.models'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; -import { HttpClient } from '@angular/common/http'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { ChangeAnalyzer } from './change-analyzer'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; -import { MetadataSchema } from '../metadata/metadata-schema.model'; +import { RequestService } from './request.service'; /* tslint:disable:max-classes-per-file */ class DataServiceImpl extends DataService { @@ -23,7 +22,6 @@ class DataServiceImpl extends DataService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, @@ -33,15 +31,13 @@ class DataServiceImpl extends DataService { super(); } - getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { - return this.halService.getEndpoint(linkPath); - } } /** * A service responsible for fetching/sending data from/to the REST API on the metadataschemas endpoint */ @Injectable() +@dataService(METADATA_SCHEMA) export class MetadataSchemaDataService { private dataService: DataServiceImpl; @@ -52,9 +48,8 @@ export class MetadataSchemaDataService { protected halService: HALEndpointService, protected objectCache: ObjectCacheService, protected comparator: DefaultChangeAnalyzer, - protected dataBuildService: NormalizedObjectBuildService, protected http: HttpClient, protected notificationsService: NotificationsService) { - this.dataService = new DataServiceImpl(requestService, rdbService, dataBuildService, null, objectCache, halService, notificationsService, http, comparator); + this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); } } diff --git a/src/app/core/data/metadatafield-parsing.service.ts b/src/app/core/data/metadatafield-parsing.service.ts index 092285e9c5..08f7892ac7 100644 --- a/src/app/core/data/metadatafield-parsing.service.ts +++ b/src/app/core/data/metadatafield-parsing.service.ts @@ -1,10 +1,10 @@ -import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; -import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { RestRequest } from './request.models'; -import { ResponseParsingService } from './parsing.service'; import { Injectable } from '@angular/core'; import { MetadatafieldSuccessResponse, RestResponse } from '../cache/response.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; import { MetadataField } from '../metadata/metadata-field.model'; +import { ResponseParsingService } from './parsing.service'; +import { RestRequest } from './request.models'; /** * A service responsible for parsing DSpaceRESTV2Response data related to a single MetadataField to a valid RestResponse @@ -15,7 +15,7 @@ export class MetadatafieldParsingService implements ResponseParsingService { parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { const payload = data.payload; - const deserialized = new DSpaceRESTv2Serializer(MetadataField).deserialize(payload); + const deserialized = new DSpaceSerializer(MetadataField).deserialize(payload); return new MetadatafieldSuccessResponse(deserialized, data.statusCode, data.statusText); } diff --git a/src/app/core/data/metadataschema-parsing.service.ts b/src/app/core/data/metadataschema-parsing.service.ts index 3e9fd257bb..f4b90e5dcd 100644 --- a/src/app/core/data/metadataschema-parsing.service.ts +++ b/src/app/core/data/metadataschema-parsing.service.ts @@ -1,10 +1,10 @@ -import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; -import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { RestRequest } from './request.models'; -import { ResponseParsingService } from './parsing.service'; import { Injectable } from '@angular/core'; import { MetadataschemaSuccessResponse, RestResponse } from '../cache/response.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; import { MetadataSchema } from '../metadata/metadata-schema.model'; +import { ResponseParsingService } from './parsing.service'; +import { RestRequest } from './request.models'; @Injectable() export class MetadataschemaParsingService implements ResponseParsingService { @@ -12,7 +12,7 @@ export class MetadataschemaParsingService implements ResponseParsingService { parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { const payload = data.payload; - const deserialized = new DSpaceRESTv2Serializer(MetadataSchema).deserialize(payload); + const deserialized = new DSpaceSerializer(MetadataSchema).deserialize(payload); return new MetadataschemaSuccessResponse(deserialized, data.statusCode, data.statusText); } diff --git a/src/app/core/data/mydspace-response-parsing.service.ts b/src/app/core/data/mydspace-response-parsing.service.ts index bd5d5b1083..062bafab46 100644 --- a/src/app/core/data/mydspace-response-parsing.service.ts +++ b/src/app/core/data/mydspace-response-parsing.service.ts @@ -1,10 +1,10 @@ import { Injectable } from '@angular/core'; import { RestResponse, SearchSuccessResponse } from '../cache/response.models'; +import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; import { DSOResponseParsingService } from './dso-response-parsing.service'; import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; import { hasValue } from '../../shared/empty.util'; import { SearchQueryResponse } from '../../shared/search/search-query-response.model'; import { MetadataMap, MetadataValue } from '../shared/metadata.models'; @@ -57,7 +57,7 @@ export class MyDSpaceResponseParsingService implements ResponseParsingService { _embedded: this.filterEmbeddedObjects(object) })); payload.objects = objects; - const deserialized = new DSpaceRESTv2Serializer(SearchQueryResponse).deserialize(payload); + const deserialized = new DSpaceSerializer(SearchQueryResponse).deserialize(payload); return new SearchSuccessResponse(deserialized, data.statusCode, data.statusText, this.dsoParser.processPageInfo(payload)); } diff --git a/src/app/core/data/object-updates/object-updates.actions.ts b/src/app/core/data/object-updates/object-updates.actions.ts index 6cd74b2626..94918157ee 100644 --- a/src/app/core/data/object-updates/object-updates.actions.ts +++ b/src/app/core/data/object-updates/object-updates.actions.ts @@ -1,20 +1,24 @@ -import { type } from '../../../shared/ngrx/type'; -import { Action } from '@ngrx/store'; -import { Identifiable } from './object-updates.reducer'; -import { INotification } from '../../../shared/notifications/models/notification.model'; +import {type} from '../../../shared/ngrx/type'; +import {Action} from '@ngrx/store'; +import {Identifiable} from './object-updates.reducer'; +import {INotification} from '../../../shared/notifications/models/notification.model'; /** * The list of ObjectUpdatesAction type definitions */ export const ObjectUpdatesActionTypes = { INITIALIZE_FIELDS: type('dspace/core/cache/object-updates/INITIALIZE_FIELDS'), + ADD_PAGE_TO_CUSTOM_ORDER: type('dspace/core/cache/object-updates/ADD_PAGE_TO_CUSTOM_ORDER'), SET_EDITABLE_FIELD: type('dspace/core/cache/object-updates/SET_EDITABLE_FIELD'), SET_VALID_FIELD: type('dspace/core/cache/object-updates/SET_VALID_FIELD'), ADD_FIELD: type('dspace/core/cache/object-updates/ADD_FIELD'), + SELECT_VIRTUAL_METADATA: type('dspace/core/cache/object-updates/SELECT_VIRTUAL_METADATA'), DISCARD: type('dspace/core/cache/object-updates/DISCARD'), REINSTATE: type('dspace/core/cache/object-updates/REINSTATE'), REMOVE: type('dspace/core/cache/object-updates/REMOVE'), + REMOVE_ALL: type('dspace/core/cache/object-updates/REMOVE_ALL'), REMOVE_FIELD: type('dspace/core/cache/object-updates/REMOVE_FIELD'), + MOVE: type('dspace/core/cache/object-updates/MOVE'), }; /* tslint:disable:max-classes-per-file */ @@ -25,7 +29,8 @@ export const ObjectUpdatesActionTypes = { export enum FieldChangeType { UPDATE = 0, ADD = 1, - REMOVE = 2 + REMOVE = 2, + MOVE = 3 } /** @@ -36,7 +41,10 @@ export class InitializeFieldsAction implements Action { payload: { url: string, fields: Identifiable[], - lastModified: Date + lastModified: Date, + order: string[], + pageSize: number, + page: number }; /** @@ -46,13 +54,49 @@ export class InitializeFieldsAction implements Action { * the unique url of the page for which the fields are being initialized * @param fields The identifiable fields of which the updates are kept track of * @param lastModified The last modified date of the object that belongs to the page + * @param order A custom order to keep track of objects moving around + * @param pageSize The page size used to fill empty pages for the custom order + * @param page The first page to populate in the custom order */ constructor( url: string, fields: Identifiable[], - lastModified: Date + lastModified: Date, + order: string[] = [], + pageSize: number = 9999, + page: number = 0 ) { - this.payload = { url, fields, lastModified }; + this.payload = { url, fields, lastModified, order, pageSize, page }; + } +} + +/** + * An ngrx action to initialize a new page's fields in the ObjectUpdates state + */ +export class AddPageToCustomOrderAction implements Action { + type = ObjectUpdatesActionTypes.ADD_PAGE_TO_CUSTOM_ORDER; + payload: { + url: string, + fields: Identifiable[], + order: string[], + page: number + }; + + /** + * Create a new AddPageToCustomOrderAction + * + * @param url The unique url of the page for which the fields are being added + * @param fields The identifiable fields of which the updates are kept track of + * @param order A custom order to keep track of objects moving around + * @param page The page to populate in the custom order + */ + constructor( + url: string, + fields: Identifiable[], + order: string[] = [], + page: number = 0 + ) { + this.payload = { url, fields, order, page }; } } @@ -83,6 +127,41 @@ export class AddFieldUpdateAction implements Action { } } +/** + * An ngrx action to select/deselect virtual metadata in the ObjectUpdates state for a certain page url + */ +export class SelectVirtualMetadataAction implements Action { + + type = ObjectUpdatesActionTypes.SELECT_VIRTUAL_METADATA; + payload: { + url: string, + source: string, + uuid: string, + select: boolean; + }; + + /** + * Create a new SelectVirtualMetadataAction + * + * @param url + * the unique url of the page for which a field update is added + * @param source + * the id of the relationship which adds the virtual metadata + * @param uuid + * the id of the item which has the virtual metadata + * @param select + * whether to select or deselect the virtual metadata to be saved as real metadata + */ + constructor( + url: string, + source: string, + uuid: string, + select: boolean, + ) { + this.payload = { url, source, uuid, select: select}; + } +} + /** * An ngrx action to set the editable state of an existing field in the ObjectUpdates state for a certain page url */ @@ -144,7 +223,8 @@ export class DiscardObjectUpdatesAction implements Action { type = ObjectUpdatesActionTypes.DISCARD; payload: { url: string, - notification: INotification + notification: INotification, + discardAll: boolean; }; /** @@ -153,12 +233,14 @@ export class DiscardObjectUpdatesAction implements Action { * @param url * the unique url of the page for which the changes should be discarded * @param notification The notification that is raised when changes are discarded + * @param discardAll discard all */ constructor( url: string, - notification: INotification + notification: INotification, + discardAll = false ) { - this.payload = { url, notification }; + this.payload = { url, notification, discardAll }; } } @@ -206,6 +288,13 @@ export class RemoveObjectUpdatesAction implements Action { } } +/** + * An ngrx action to remove all previously discarded updates in the ObjectUpdates state + */ +export class RemoveAllObjectUpdatesAction implements Action { + type = ObjectUpdatesActionTypes.REMOVE_ALL; +} + /** * An ngrx action to remove a single field update in the ObjectUpdates state for a certain page url and field uuid */ @@ -231,6 +320,43 @@ export class RemoveFieldUpdateAction implements Action { } } +/** + * An ngrx action to remove a single field update in the ObjectUpdates state for a certain page url and field uuid + */ +export class MoveFieldUpdateAction implements Action { + type = ObjectUpdatesActionTypes.MOVE; + payload: { + url: string, + from: number, + to: number, + fromPage: number, + toPage: number, + field?: Identifiable + }; + + /** + * Create a new RemoveObjectUpdatesAction + * + * @param url + * the unique url of the page for which a field's change should be removed + * @param from The index of the object to move + * @param to The index to move the object to + * @param fromPage The page to move the object from + * @param toPage The page to move the object to + * @param field Optional field to add to the fieldUpdates list (useful when we want to track updates across multiple pages) + */ + constructor( + url: string, + from: number, + to: number, + fromPage: number, + toPage: number, + field?: Identifiable + ) { + this.payload = { url, from, to, fromPage, toPage, field }; + } +} + /* tslint:enable:max-classes-per-file */ /** @@ -242,4 +368,10 @@ export type ObjectUpdatesAction | DiscardObjectUpdatesAction | ReinstateObjectUpdatesAction | RemoveObjectUpdatesAction - | RemoveFieldUpdateAction; + | RemoveFieldUpdateAction + | MoveFieldUpdateAction + | AddPageToCustomOrderAction + | RemoveAllObjectUpdatesAction + | SelectVirtualMetadataAction + | SetEditableFieldUpdateAction + | SetValidFieldUpdateAction; diff --git a/src/app/core/data/object-updates/object-updates.effects.ts b/src/app/core/data/object-updates/object-updates.effects.ts index 88cd3bc718..239fee9477 100644 --- a/src/app/core/data/object-updates/object-updates.effects.ts +++ b/src/app/core/data/object-updates/object-updates.effects.ts @@ -3,12 +3,12 @@ import { Actions, Effect, ofType } from '@ngrx/effects'; import { DiscardObjectUpdatesAction, ObjectUpdatesAction, - ObjectUpdatesActionTypes, + ObjectUpdatesActionTypes, RemoveAllObjectUpdatesAction, RemoveObjectUpdatesAction } from './object-updates.actions'; import { delay, filter, map, switchMap, take, tap } from 'rxjs/operators'; import { of as observableOf, race as observableRace, Subject } from 'rxjs'; -import { hasNoValue } from '../../../shared/empty.util'; +import { hasNoValue, hasValue } from '../../../shared/empty.util'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { INotification } from '../../../shared/notifications/models/notification.model'; import { @@ -16,6 +16,7 @@ import { NotificationsActionTypes, RemoveNotificationAction } from '../../../shared/notifications/notifications.actions'; +import { Action } from '@ngrx/store'; /** * NGRX effects for ObjectUpdatesActions @@ -53,13 +54,14 @@ export class ObjectUpdatesEffects { .pipe( ofType(...Object.values(ObjectUpdatesActionTypes)), map((action: ObjectUpdatesAction) => { - const url: string = action.payload.url; + if (hasValue((action as any).payload)) { + const url: string = (action as any).payload.url; if (hasNoValue(this.actionMap$[url])) { this.actionMap$[url] = new Subject(); } this.actionMap$[url].next(action); } - ) + }) ); /** @@ -91,9 +93,15 @@ export class ObjectUpdatesEffects { const url: string = action.payload.url; const notification: INotification = action.payload.notification; const timeOut = notification.options.timeOut; + + let removeAction: Action = new RemoveObjectUpdatesAction(action.payload.url); + if (action.payload.discardAll) { + removeAction = new RemoveAllObjectUpdatesAction(); + } + return observableRace( // Either wait for the delay and perform a remove action - observableOf(new RemoveObjectUpdatesAction(action.payload.url)).pipe(delay(timeOut)), + observableOf(removeAction).pipe(delay(timeOut)), // Or wait for a a user action this.actionMap$[url].pipe( take(1), @@ -106,19 +114,19 @@ export class ObjectUpdatesEffects { return { type: 'NO_ACTION' } } // If someone performed another action, assume the user does not want to reinstate and remove all changes - return new RemoveObjectUpdatesAction(action.payload.url); + return removeAction }) ), this.notificationActionMap$[notification.id].pipe( filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_NOTIFICATION), map(() => { - return new RemoveObjectUpdatesAction(action.payload.url); + return removeAction; }) ), this.notificationActionMap$[this.allIdentifier].pipe( filter((notificationsAction: NotificationsActions) => notificationsAction.type === NotificationsActionTypes.REMOVE_ALL_NOTIFICATIONS), map(() => { - return new RemoveObjectUpdatesAction(action.payload.url); + return removeAction; }) ) ) diff --git a/src/app/core/data/object-updates/object-updates.reducer.spec.ts b/src/app/core/data/object-updates/object-updates.reducer.spec.ts index f5698b9b78..bdf202049e 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.spec.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.spec.ts @@ -1,14 +1,15 @@ import * as deepFreeze from 'deep-freeze'; import { - AddFieldUpdateAction, + AddFieldUpdateAction, AddPageToCustomOrderAction, DiscardObjectUpdatesAction, FieldChangeType, - InitializeFieldsAction, - ReinstateObjectUpdatesAction, - RemoveFieldUpdateAction, RemoveObjectUpdatesAction, + InitializeFieldsAction, MoveFieldUpdateAction, + ReinstateObjectUpdatesAction, RemoveAllObjectUpdatesAction, + RemoveFieldUpdateAction, RemoveObjectUpdatesAction, SelectVirtualMetadataAction, SetEditableFieldUpdateAction, SetValidFieldUpdateAction } from './object-updates.actions'; import { OBJECT_UPDATES_TRASH_PATH, objectUpdatesReducer } from './object-updates.reducer'; +import {Relationship} from '../../shared/item-relationships/relationship.model'; class NullAction extends RemoveFieldUpdateAction { type = null; @@ -44,6 +45,7 @@ const identifiable3 = { language: null, value: 'Unchanged value' }; +const relationship: Relationship = Object.assign(new Relationship(), {uuid: 'test relationship uuid'}); const modDate = new Date(2010, 2, 11); const uuid = identifiable1.uuid; @@ -79,7 +81,20 @@ describe('objectUpdatesReducer', () => { changeType: FieldChangeType.ADD } }, - lastModified: modDate + lastModified: modDate, + virtualMetadataSources: { + [relationship.uuid]: {[identifiable1.uuid]: true} + }, + customOrder: { + initialOrderPages: [ + { order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] } + ], + newOrderPages: [ + { order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] } + ], + pageSize: 10, + changed: false + } } }; @@ -102,7 +117,20 @@ describe('objectUpdatesReducer', () => { isValid: true }, }, - lastModified: modDate + lastModified: modDate, + virtualMetadataSources: { + [relationship.uuid]: {[identifiable1.uuid]: true} + }, + customOrder: { + initialOrderPages: [ + { order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] } + ], + newOrderPages: [ + { order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] } + ], + pageSize: 10, + changed: false + } }, [url + OBJECT_UPDATES_TRASH_PATH]: { fieldStates: { @@ -133,7 +161,20 @@ describe('objectUpdatesReducer', () => { changeType: FieldChangeType.ADD } }, - lastModified: modDate + lastModified: modDate, + virtualMetadataSources: { + [relationship.uuid]: {[identifiable1.uuid]: true} + }, + customOrder: { + initialOrderPages: [ + { order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] } + ], + newOrderPages: [ + { order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] } + ], + pageSize: 10, + changed: false + } } }; @@ -195,8 +236,14 @@ describe('objectUpdatesReducer', () => { objectUpdatesReducer(testState, action); }); + it('should perform the SELECT_VIRTUAL_METADATA action without affecting the previous state', () => { + const action = new SelectVirtualMetadataAction(url, relationship.uuid, identifiable1.uuid, true); + // testState has already been frozen above + objectUpdatesReducer(testState, action); + }); + it('should initialize all fields when the INITIALIZE action is dispatched, based on the payload', () => { - const action = new InitializeFieldsAction(url, [identifiable1, identifiable3], modDate); + const action = new InitializeFieldsAction(url, [identifiable1, identifiable3], modDate, [identifiable1.uuid, identifiable3.uuid], 10, 0); const expectedState = { [url]: { @@ -213,7 +260,18 @@ describe('objectUpdatesReducer', () => { }, }, fieldUpdates: {}, - lastModified: modDate + virtualMetadataSources: {}, + lastModified: modDate, + customOrder: { + initialOrderPages: [ + { order: [identifiable1.uuid, identifiable3.uuid] } + ], + newOrderPages: [ + { order: [identifiable1.uuid, identifiable3.uuid] } + ], + pageSize: 10, + changed: false + } } }; const newState = objectUpdatesReducer(testState, action); @@ -265,10 +323,44 @@ describe('objectUpdatesReducer', () => { expect(newState[url + OBJECT_UPDATES_TRASH_PATH]).toBeUndefined(); }); + it('should remove all updates from the state when the REMOVE_ALL action is dispatched', () => { + const action = new RemoveAllObjectUpdatesAction(); + + const newState = objectUpdatesReducer(discardedTestState, action as any); + expect(newState[url].fieldUpdates).toBeUndefined(); + expect(newState[url + OBJECT_UPDATES_TRASH_PATH]).toBeUndefined(); + }); + it('should remove a given field\'s update from the state when the REMOVE_FIELD action is dispatched, based on the payload', () => { const action = new RemoveFieldUpdateAction(url, uuid); const newState = objectUpdatesReducer(testState, action); expect(newState[url].fieldUpdates[uuid]).toBeUndefined(); }); + + it('should move the custom order from the state when the MOVE action is dispatched', () => { + const action = new MoveFieldUpdateAction(url, 0, 1, 0, 0); + + const newState = objectUpdatesReducer(testState, action); + expect(newState[url].customOrder.newOrderPages[0].order[0]).toEqual(testState[url].customOrder.newOrderPages[0].order[1]); + expect(newState[url].customOrder.newOrderPages[0].order[1]).toEqual(testState[url].customOrder.newOrderPages[0].order[0]); + expect(newState[url].customOrder.changed).toEqual(true); + }); + + it('should add a new page to the custom order and add empty pages in between when the ADD_PAGE_TO_CUSTOM_ORDER action is dispatched', () => { + const identifiable4 = { + uuid: 'a23eae5a-7857-4ef9-8e52-989436ad2955', + key: 'dc.description.abstract', + language: null, + value: 'Extra value' + }; + const action = new AddPageToCustomOrderAction(url, [identifiable4], [identifiable4.uuid], 2); + + const newState = objectUpdatesReducer(testState, action); + // Confirm the page in between the two pages (index 1) has been filled with 10 (page size) undefined values + expect(newState[url].customOrder.newOrderPages[1].order.length).toEqual(10); + expect(newState[url].customOrder.newOrderPages[1].order[0]).toBeUndefined(); + // Verify the new page is correct + expect(newState[url].customOrder.newOrderPages[2].order[0]).toEqual(identifiable4.uuid); + }); }); diff --git a/src/app/core/data/object-updates/object-updates.reducer.ts b/src/app/core/data/object-updates/object-updates.reducer.ts index c0f10ff92a..759a9f5c87 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.ts @@ -1,15 +1,21 @@ import { - AddFieldUpdateAction, + AddFieldUpdateAction, AddPageToCustomOrderAction, DiscardObjectUpdatesAction, FieldChangeType, - InitializeFieldsAction, + InitializeFieldsAction, MoveFieldUpdateAction, ObjectUpdatesAction, ObjectUpdatesActionTypes, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, - RemoveObjectUpdatesAction, SetEditableFieldUpdateAction, SetValidFieldUpdateAction + RemoveObjectUpdatesAction, + SetEditableFieldUpdateAction, + SetValidFieldUpdateAction, + SelectVirtualMetadataAction, } from './object-updates.actions'; -import { hasNoValue, hasValue } from '../../../shared/empty.util'; +import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; +import { moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop'; +import { from } from 'rxjs/internal/observable/from'; +import {Relationship} from '../../shared/item-relationships/relationship.model'; /** * Path where discarded objects are saved @@ -54,13 +60,52 @@ export interface FieldUpdates { [uuid: string]: FieldUpdate; } +/** + * The states of all virtual metadata selections available for a single page, mapped by the relationship uuid + */ +export interface VirtualMetadataSources { + [source: string]: VirtualMetadataSource +} + +/** + * The selection of virtual metadata for a relationship, mapped by the uuid of either the item or the relationship type + */ +export interface VirtualMetadataSource { + [uuid: string]: boolean, +} + +/** + * A fieldupdate interface which represents a relationship selected to be deleted, + * along with a selection of the virtual metadata to keep + */ +export interface DeleteRelationship extends Relationship { + keepLeftVirtualMetadata: boolean, + keepRightVirtualMetadata: boolean, +} + +/** + * A custom order given to the list of objects + */ +export interface CustomOrder { + initialOrderPages: OrderPage[], + newOrderPages: OrderPage[], + pageSize: number; + changed: boolean +} + +export interface OrderPage { + order: string[] +} + /** * The updated state of a single page */ export interface ObjectUpdatesEntry { fieldStates: FieldStates; - fieldUpdates: FieldUpdates + fieldUpdates: FieldUpdates; + virtualMetadataSources: VirtualMetadataSources; lastModified: Date; + customOrder: CustomOrder } /** @@ -93,9 +138,15 @@ export function objectUpdatesReducer(state = initialState, action: ObjectUpdates case ObjectUpdatesActionTypes.INITIALIZE_FIELDS: { return initializeFieldsUpdate(state, action as InitializeFieldsAction); } + case ObjectUpdatesActionTypes.ADD_PAGE_TO_CUSTOM_ORDER: { + return addPageToCustomOrder(state, action as AddPageToCustomOrderAction); + } case ObjectUpdatesActionTypes.ADD_FIELD: { return addFieldUpdate(state, action as AddFieldUpdateAction); } + case ObjectUpdatesActionTypes.SELECT_VIRTUAL_METADATA: { + return selectVirtualMetadata(state, action as SelectVirtualMetadataAction); + } case ObjectUpdatesActionTypes.DISCARD: { return discardObjectUpdates(state, action as DiscardObjectUpdatesAction); } @@ -105,6 +156,9 @@ export function objectUpdatesReducer(state = initialState, action: ObjectUpdates case ObjectUpdatesActionTypes.REMOVE: { return removeObjectUpdates(state, action as RemoveObjectUpdatesAction); } + case ObjectUpdatesActionTypes.REMOVE_ALL: { + return removeAllObjectUpdates(state); + } case ObjectUpdatesActionTypes.REMOVE_FIELD: { return removeFieldUpdate(state, action as RemoveFieldUpdateAction); } @@ -114,6 +168,9 @@ export function objectUpdatesReducer(state = initialState, action: ObjectUpdates case ObjectUpdatesActionTypes.SET_VALID_FIELD: { return setValidFieldUpdate(state, action as SetValidFieldUpdateAction); } + case ObjectUpdatesActionTypes.MOVE: { + return moveFieldUpdate(state, action as MoveFieldUpdateAction); + } default: { return state; } @@ -129,17 +186,50 @@ function initializeFieldsUpdate(state: any, action: InitializeFieldsAction) { const url: string = action.payload.url; const fields: Identifiable[] = action.payload.fields; const lastModifiedServer: Date = action.payload.lastModified; + const order = action.payload.order; + const pageSize = action.payload.pageSize; + const page = action.payload.page; const fieldStates = createInitialFieldStates(fields); + const initialOrderPages = addOrderToPages([], order, pageSize, page); const newPageState = Object.assign( {}, state[url], { fieldStates: fieldStates }, { fieldUpdates: {} }, - { lastModified: lastModifiedServer } + { virtualMetadataSources: {} }, + { lastModified: lastModifiedServer }, + { customOrder: { + initialOrderPages: initialOrderPages, + newOrderPages: initialOrderPages, + pageSize: pageSize, + changed: false } + } ); return Object.assign({}, state, { [url]: newPageState }); } +/** + * Add a page of objects to the state of a specific url and update a specific page of the custom order + * @param state The current state + * @param action The action to perform on the current state + */ +function addPageToCustomOrder(state: any, action: AddPageToCustomOrderAction) { + const url: string = action.payload.url; + const fields: Identifiable[] = action.payload.fields; + const fieldStates = createInitialFieldStates(fields); + const order = action.payload.order; + const page = action.payload.page; + const pageState: ObjectUpdatesEntry = state[url] || {}; + const newPageState = Object.assign({}, pageState, { + fieldStates: Object.assign({}, pageState.fieldStates, fieldStates), + customOrder: Object.assign({}, pageState.customOrder, { + newOrderPages: addOrderToPages(pageState.customOrder.newOrderPages, order, pageState.customOrder.pageSize, page), + initialOrderPages: addOrderToPages(pageState.customOrder.initialOrderPages, order, pageState.customOrder.pageSize, page) + }) + }); + return Object.assign({}, state, { [url]: newPageState }); +} + /** * Add a new update for a specific field to the store * @param state The current state @@ -169,13 +259,75 @@ function addFieldUpdate(state: any, action: AddFieldUpdateAction) { return Object.assign({}, state, { [url]: newPageState }); } +/** + * Update the selected virtual metadata in the store + * @param state The current state + * @param action The action to perform on the current state + */ +function selectVirtualMetadata(state: any, action: SelectVirtualMetadataAction) { + + const url: string = action.payload.url; + const source: string = action.payload.source; + const uuid: string = action.payload.uuid; + const select: boolean = action.payload.select; + + const pageState: ObjectUpdatesEntry = state[url] || {}; + + const virtualMetadataSource = Object.assign( + {}, + pageState.virtualMetadataSources[source], + { + [uuid]: select, + }, + ); + + const virtualMetadataSources = Object.assign( + {}, + pageState.virtualMetadataSources, + { + [source]: virtualMetadataSource, + }, + ); + + const newPageState = Object.assign( + {}, + pageState, + {virtualMetadataSources: virtualMetadataSources}, + ); + + return Object.assign( + {}, + state, + { + [url]: newPageState, + } + ); +} + /** * Discard all updates for a specific action's url in the store * @param state The current state * @param action The action to perform on the current state */ function discardObjectUpdates(state: any, action: DiscardObjectUpdatesAction) { - const url: string = action.payload.url; + if (action.payload.discardAll) { + let newState = Object.assign({}, state); + Object.keys(state).filter((path: string) => !path.endsWith(OBJECT_UPDATES_TRASH_PATH)).forEach((path: string) => { + newState = discardObjectUpdatesFor(path, newState); + }); + return newState; + } else { + const url: string = action.payload.url; + return discardObjectUpdatesFor(url, state); + } +} + +/** + * Discard all updates for a specific action's url in the store + * @param url The action's url + * @param state The current state + */ +function discardObjectUpdatesFor(url: string, state: any) { const pageState: ObjectUpdatesEntry = state[url]; const newFieldStates = {}; Object.keys(pageState.fieldStates).forEach((uuid: string) => { @@ -186,9 +338,19 @@ function discardObjectUpdates(state: any, action: DiscardObjectUpdatesAction) { } }); + const newCustomOrder = Object.assign({}, pageState.customOrder); + if (pageState.customOrder.changed) { + const initialOrder = pageState.customOrder.initialOrderPages; + if (isNotEmpty(initialOrder)) { + newCustomOrder.newOrderPages = initialOrder; + newCustomOrder.changed = false; + } + } + const discardedPageState = Object.assign({}, pageState, { fieldUpdates: {}, - fieldStates: newFieldStates + fieldStates: newFieldStates, + customOrder: newCustomOrder }); return Object.assign({}, state, { [url]: discardedPageState }, { [url + OBJECT_UPDATES_TRASH_PATH]: pageState }); } @@ -228,6 +390,18 @@ function removeObjectUpdatesByURL(state: any, url: string) { return newState; } +/** + * Remove all updates in the store + * @param state The current state + */ +function removeAllObjectUpdates(state: any) { + const newState = Object.assign({}, state); + Object.keys(state).filter((path: string) => path.endsWith(OBJECT_UPDATES_TRASH_PATH)).forEach((path: string) => { + delete newState[path]; + }); + return newState; +} + /** * Discard the update for a specific action's url and field UUID in the store * @param state The current state @@ -330,3 +504,121 @@ function createInitialFieldStates(fields: Identifiable[]) { uuids.forEach((uuid: string) => fieldStates[uuid] = initialFieldState); return fieldStates; } + +/** + * Method to add a list of objects to an existing FieldStates object + * @param fieldStates FieldStates to add states to + * @param fields Identifiable objects The list of objects to add to the FieldStates + */ +function addFieldStates(fieldStates: FieldStates, fields: Identifiable[]) { + const uuids = fields.map((field: Identifiable) => field.uuid); + uuids.forEach((uuid: string) => fieldStates[uuid] = initialFieldState); + return fieldStates; +} + +/** + * Move an object within the custom order of a page state + * @param state The current state + * @param action The move action to perform + */ +function moveFieldUpdate(state: any, action: MoveFieldUpdateAction) { + const url = action.payload.url; + const fromIndex = action.payload.from; + const toIndex = action.payload.to; + const fromPage = action.payload.fromPage; + const toPage = action.payload.toPage; + const field = action.payload.field; + + const pageState: ObjectUpdatesEntry = state[url]; + const initialOrderPages = pageState.customOrder.initialOrderPages; + const customOrderPages = [...pageState.customOrder.newOrderPages]; + + // Create a copy of the custom orders for the from- and to-pages + const fromPageOrder = [...customOrderPages[fromPage].order]; + const toPageOrder = [...customOrderPages[toPage].order]; + if (fromPage === toPage) { + if (isNotEmpty(customOrderPages[fromPage]) && isNotEmpty(customOrderPages[fromPage].order[fromIndex]) && isNotEmpty(customOrderPages[fromPage].order[toIndex])) { + // Move an item from one index to another within the same page + moveItemInArray(fromPageOrder, fromIndex, toIndex); + // Update the custom order for this page + customOrderPages[fromPage] = { order: fromPageOrder }; + } + } else { + if (isNotEmpty(customOrderPages[fromPage]) && hasValue(customOrderPages[toPage]) && isNotEmpty(customOrderPages[fromPage].order[fromIndex])) { + // Move an item from one index of one page to an index in another page + transferArrayItem(fromPageOrder, toPageOrder, fromIndex, toIndex); + // Update the custom order for both pages + customOrderPages[fromPage] = { order: fromPageOrder }; + customOrderPages[toPage] = { order: toPageOrder }; + } + } + + // Create a field update if it doesn't exist for this field yet + let fieldUpdate = {}; + if (hasValue(field)) { + fieldUpdate = pageState.fieldUpdates[field.uuid]; + if (hasNoValue(fieldUpdate)) { + fieldUpdate = { field: field, changeType: undefined } + } + } + + // Update the store's state with new values and return + return Object.assign({}, state, { [url]: Object.assign({}, pageState, { + fieldUpdates: Object.assign({}, pageState.fieldUpdates, hasValue(field) ? { [field.uuid]: fieldUpdate } : {}), + customOrder: Object.assign({}, pageState.customOrder, { newOrderPages: customOrderPages, changed: checkForOrderChanges(initialOrderPages, customOrderPages) }) + })}) +} + +/** + * Compare two lists of OrderPage objects and return whether there's at least one change in the order of objects within + * @param initialOrderPages The initial list of OrderPages + * @param customOrderPages The changed list of OrderPages + */ +function checkForOrderChanges(initialOrderPages: OrderPage[], customOrderPages: OrderPage[]) { + let changed = false; + initialOrderPages.forEach((orderPage: OrderPage, page: number) => { + if (isNotEmpty(orderPage) && isNotEmpty(orderPage.order) && isNotEmpty(customOrderPages[page]) && isNotEmpty(customOrderPages[page].order)) { + orderPage.order.forEach((id: string, index: number) => { + if (id !== customOrderPages[page].order[index]) { + changed = true; + return; + } + }); + if (changed) { + return; + } + } + }); + return changed; +} + +/** + * Initialize a custom order page by providing the list of all pages, a list of UUIDs, pageSize and the page to populate + * @param initialPages The initial list of OrderPage objects + * @param order The list of UUIDs to create a page for + * @param pageSize The pageSize used to populate empty spacer pages + * @param page The index of the page to add + */ +function addOrderToPages(initialPages: OrderPage[], order: string[], pageSize: number, page: number): OrderPage[] { + const result = [...initialPages]; + const orderPage: OrderPage = { order: order }; + if (page < result.length) { + // The page we're trying to add already exists in the list. Overwrite it. + result[page] = orderPage; + } else if (page === result.length) { + // The page we're trying to add is the next page in the list, add it. + result.push(orderPage); + } else { + // The page we're trying to add is at least one page ahead of the list, fill the list with empty pages before adding the page. + const emptyOrder = []; + for (let i = 0; i < pageSize; i++) { + emptyOrder.push(undefined); + } + const emptyOrderPage: OrderPage = { order: emptyOrder }; + for (let i = result.length; i < page; i++) { + result.push(emptyOrderPage); + } + result.push(orderPage); + } + return result; +} diff --git a/src/app/core/data/object-updates/object-updates.service.spec.ts b/src/app/core/data/object-updates/object-updates.service.spec.ts index e9fc4652b0..780a402a84 100644 --- a/src/app/core/data/object-updates/object-updates.service.spec.ts +++ b/src/app/core/data/object-updates/object-updates.service.spec.ts @@ -2,15 +2,19 @@ import { Store } from '@ngrx/store'; import { CoreState } from '../../core.reducers'; import { ObjectUpdatesService } from './object-updates.service'; import { + AddPageToCustomOrderAction, DiscardObjectUpdatesAction, FieldChangeType, - InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, + InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, SelectVirtualMetadataAction, SetEditableFieldUpdateAction } from './object-updates.actions'; import { of as observableOf } from 'rxjs'; import { Notification } from '../../../shared/notifications/models/notification.model'; import { NotificationType } from '../../../shared/notifications/models/notification-type'; import { OBJECT_UPDATES_TRASH_PATH } from './object-updates.reducer'; +import {Relationship} from '../../shared/item-relationships/relationship.model'; +import { MoveOperation } from 'fast-json-patch/lib/core'; +import { ArrayMoveChangeAnalyzer } from '../array-move-change-analyzer.service'; describe('ObjectUpdatesService', () => { let service: ObjectUpdatesService; @@ -22,6 +26,7 @@ describe('ObjectUpdatesService', () => { const identifiable2 = { uuid: '26cbb5ce-5786-4e57-a394-b9fcf8eaf241' }; const identifiable3 = { uuid: 'c5d2c2f7-d757-48bf-84cc-8c9229c8407e' }; const identifiables = [identifiable1, identifiable2]; + const relationship: Relationship = Object.assign(new Relationship(), {uuid: 'test relationship uuid'}); const fieldUpdates = { [identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE }, @@ -38,11 +43,11 @@ describe('ObjectUpdatesService', () => { }; const objectEntry = { - fieldStates, fieldUpdates, lastModified: modDate + fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {} }; store = new Store(undefined, undefined, undefined); spyOn(store, 'dispatch'); - service = new ObjectUpdatesService(store); + service = new ObjectUpdatesService(store, new ArrayMoveChangeAnalyzer()); spyOn(service as any, 'getObjectEntry').and.returnValue(observableOf(objectEntry)); spyOn(service as any, 'getFieldState').and.callFake((uuid) => { @@ -58,6 +63,25 @@ describe('ObjectUpdatesService', () => { }); }); + describe('initializeWithCustomOrder', () => { + const pageSize = 20; + const page = 0; + + it('should dispatch an INITIALIZE action with the correct URL, initial identifiables, last modified , custom order, page size and page', () => { + service.initializeWithCustomOrder(url, identifiables, modDate, pageSize, page); + expect(store.dispatch).toHaveBeenCalledWith(new InitializeFieldsAction(url, identifiables, modDate, identifiables.map((identifiable) => identifiable.uuid), pageSize, page)); + }); + }); + + describe('addPageToCustomOrder', () => { + const page = 2; + + it('should dispatch an ADD_PAGE_TO_CUSTOM_ORDER action with the correct URL, identifiables, custom order and page number to add', () => { + service.addPageToCustomOrder(url, identifiables, page); + expect(store.dispatch).toHaveBeenCalledWith(new AddPageToCustomOrderAction(url, identifiables, identifiables.map((identifiable) => identifiable.uuid), page)); + }); + }); + describe('getFieldUpdates', () => { it('should return the list of all fields, including their update if there is one', () => { const result$ = service.getFieldUpdates(url, identifiables); @@ -75,6 +99,66 @@ describe('ObjectUpdatesService', () => { }); }); + describe('getFieldUpdatesExclusive', () => { + it('should return the list of all fields, including their update if there is one, excluding updates that aren\'t part of the initial values provided', (done) => { + const result$ = service.getFieldUpdatesExclusive(url, identifiables); + expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); + + const expectedResult = { + [identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE }, + [identifiable2.uuid]: { field: identifiable2, changeType: undefined } + }; + + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + done(); + }); + }); + }); + + describe('getFieldUpdatesByCustomOrder', () => { + beforeEach(() => { + const fieldStates = { + [identifiable1.uuid]: { editable: false, isNew: false, isValid: true }, + [identifiable2.uuid]: { editable: true, isNew: false, isValid: false }, + [identifiable3.uuid]: { editable: true, isNew: true, isValid: true }, + }; + + const customOrder = { + initialOrderPages: [{ + order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] + }], + newOrderPages: [{ + order: [identifiable2.uuid, identifiable3.uuid, identifiable1.uuid] + }], + pageSize: 20, + changed: true + }; + + const objectEntry = { + fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {}, customOrder + }; + + (service as any).getObjectEntry.and.returnValue(observableOf(objectEntry)) + }); + + it('should return the list of all fields, including their update if there is one, ordered by their custom order', (done) => { + const result$ = service.getFieldUpdatesByCustomOrder(url, identifiables); + expect((service as any).getObjectEntry).toHaveBeenCalledWith(url); + + const expectedResult = { + [identifiable2.uuid]: { field: identifiable2, changeType: undefined }, + [identifiable3.uuid]: { field: identifiable3, changeType: FieldChangeType.ADD }, + [identifiable1.uuid]: { field: identifiable1Updated, changeType: FieldChangeType.UPDATE } + }; + + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + done(); + }); + }); + }); + describe('isEditable', () => { it('should return false if this identifiable is currently not editable in the store', () => { const result$ = service.isEditable(url, identifiable1.uuid); @@ -190,7 +274,11 @@ describe('ObjectUpdatesService', () => { }); describe('when updates are emtpy', () => { beforeEach(() => { - (service as any).getObjectEntry.and.returnValue(observableOf({})) + (service as any).getObjectEntry.and.returnValue(observableOf({ + customOrder: { + changed: false + } + })) }); it('should return false when there are no updates', () => { @@ -251,4 +339,51 @@ describe('ObjectUpdatesService', () => { }); }); + describe('setSelectedVirtualMetadata', () => { + it('should dispatch a SELECT_VIRTUAL_METADATA action with the correct URL, relationship, identifiable and boolean', () => { + service.setSelectedVirtualMetadata(url, relationship.uuid, identifiable1.uuid, true); + expect(store.dispatch).toHaveBeenCalledWith(new SelectVirtualMetadataAction(url, relationship.uuid, identifiable1.uuid, true)); + }); + }); + + describe('getMoveOperations', () => { + beforeEach(() => { + const fieldStates = { + [identifiable1.uuid]: { editable: false, isNew: false, isValid: true }, + [identifiable2.uuid]: { editable: true, isNew: false, isValid: false }, + [identifiable3.uuid]: { editable: true, isNew: true, isValid: true }, + }; + + const customOrder = { + initialOrderPages: [{ + order: [identifiable1.uuid, identifiable2.uuid, identifiable3.uuid] + }], + newOrderPages: [{ + order: [identifiable2.uuid, identifiable3.uuid, identifiable1.uuid] + }], + pageSize: 20, + changed: true + }; + + const objectEntry = { + fieldStates, fieldUpdates, lastModified: modDate, virtualMetadataSources: {}, customOrder + }; + + (service as any).getObjectEntry.and.returnValue(observableOf(objectEntry)) + }); + + it('should return the expected move operations', (done) => { + const result$ = service.getMoveOperations(url); + + const expectedResult = [ + { op: 'move', from: '/0', path: '/2' } + ] as MoveOperation[]; + + result$.subscribe((result) => { + expect(result).toEqual(expectedResult); + done(); + }); + }); + }); + }); diff --git a/src/app/core/data/object-updates/object-updates.service.ts b/src/app/core/data/object-updates/object-updates.service.ts index 91185551ed..c9a7f47e81 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -8,22 +8,28 @@ import { Identifiable, OBJECT_UPDATES_TRASH_PATH, ObjectUpdatesEntry, - ObjectUpdatesState + ObjectUpdatesState, OrderPage, + VirtualMetadataSource } from './object-updates.reducer'; import { Observable } from 'rxjs'; import { - AddFieldUpdateAction, + AddFieldUpdateAction, AddPageToCustomOrderAction, DiscardObjectUpdatesAction, FieldChangeType, InitializeFieldsAction, + MoveFieldUpdateAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, + SelectVirtualMetadataAction, SetEditableFieldUpdateAction, SetValidFieldUpdateAction } from './object-updates.actions'; -import { distinctUntilChanged, filter, map } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators'; import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; import { INotification } from '../../../shared/notifications/models/notification.model'; +import { ArrayMoveChangeAnalyzer } from '../array-move-change-analyzer.service'; +import { MoveOperation } from 'fast-json-patch/lib/core'; +import { flatten } from '@angular/compiler'; function objectUpdatesStateSelector(): MemoizedSelector { return createSelector(coreSelector, (state: CoreState) => state['cache/object-updates']); @@ -37,12 +43,17 @@ function filterByUrlAndUUIDFieldStateSelector(url: string, uuid: string): Memoiz return createSelector(filterByUrlObjectUpdatesStateSelector(url), (state: ObjectUpdatesEntry) => state.fieldStates[uuid]); } +function virtualMetadataSourceSelector(url: string, source: string): MemoizedSelector { + return createSelector(filterByUrlObjectUpdatesStateSelector(url), (state: ObjectUpdatesEntry) => state.virtualMetadataSources[source]); +} + /** * Service that dispatches and reads from the ObjectUpdates' state in the store */ @Injectable() export class ObjectUpdatesService { - constructor(private store: Store) { + constructor(private store: Store, + private comparator: ArrayMoveChangeAnalyzer) { } @@ -56,6 +67,28 @@ export class ObjectUpdatesService { this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified)); } + /** + * Method to dispatch an InitializeFieldsAction to the store and keeping track of the order objects are stored + * @param url The page's URL for which the changes are being mapped + * @param fields The initial fields for the page's object + * @param lastModified The date the object was last modified + * @param pageSize The page size to use for adding pages to the custom order + * @param page The first page to populate the custom order with + */ + initializeWithCustomOrder(url, fields: Identifiable[], lastModified: Date, pageSize = 9999, page = 0): void { + this.store.dispatch(new InitializeFieldsAction(url, fields, lastModified, fields.map((field) => field.uuid), pageSize, page)); + } + + /** + * Method to dispatch an AddPageToCustomOrderAction, adding a new page to an already existing custom order tracking + * @param url The URL for which the changes are being mapped + * @param fields The fields to add a new page for + * @param page The page number (starting from index 0) + */ + addPageToCustomOrder(url, fields: Identifiable[], page: number): void { + this.store.dispatch(new AddPageToCustomOrderAction(url, fields, fields.map((field) => field.uuid), page)); + } + /** * Method to dispatch an AddFieldUpdateAction to the store * @param url The page's URL for which the changes are saved @@ -88,25 +121,28 @@ export class ObjectUpdatesService { * a FieldUpdates object * @param url The URL of the page for which the FieldUpdates should be requested * @param initialFields The initial values of the fields + * @param ignoreStates Ignore the fieldStates to loop over the fieldUpdates instead */ - getFieldUpdates(url: string, initialFields: Identifiable[]): Observable { + getFieldUpdates(url: string, initialFields: Identifiable[], ignoreStates?: boolean): Observable { const objectUpdates = this.getObjectEntry(url); - return objectUpdates.pipe(map((objectEntry) => { - const fieldUpdates: FieldUpdates = {}; - Object.keys(objectEntry.fieldStates).forEach((uuid) => { - let fieldUpdate = objectEntry.fieldUpdates[uuid]; - if (isEmpty(fieldUpdate)) { - const identifiable = initialFields.find((object: Identifiable) => object.uuid === uuid); - if (hasValue(identifiable)) { - fieldUpdate = {field: identifiable, changeType: undefined}; - } + return objectUpdates.pipe( + switchMap((objectEntry) => { + const fieldUpdates: FieldUpdates = {}; + if (hasValue(objectEntry)) { + Object.keys(ignoreStates ? objectEntry.fieldUpdates : objectEntry.fieldStates).forEach((uuid) => { + fieldUpdates[uuid] = objectEntry.fieldUpdates[uuid]; + }); } - if (hasValue(fieldUpdate)) { - fieldUpdates[uuid] = fieldUpdate; - } - }); - return fieldUpdates; - })) + return this.getFieldUpdatesExclusive(url, initialFields).pipe( + map((fieldUpdatesExclusive) => { + Object.keys(fieldUpdatesExclusive).forEach((uuid) => { + fieldUpdates[uuid] = fieldUpdatesExclusive[uuid]; + }); + return fieldUpdates; + }) + ); + }), + ); } /** @@ -130,6 +166,31 @@ export class ObjectUpdatesService { })) } + /** + * Method that combines the state's updates with the initial values (when there's no update), + * sorted by their custom order to create a FieldUpdates object + * @param url The URL of the page for which the FieldUpdates should be requested + * @param initialFields The initial values of the fields + * @param page The page to retrieve + */ + getFieldUpdatesByCustomOrder(url: string, initialFields: Identifiable[], page = 0): Observable { + const objectUpdates = this.getObjectEntry(url); + return objectUpdates.pipe(map((objectEntry) => { + const fieldUpdates: FieldUpdates = {}; + if (hasValue(objectEntry) && hasValue(objectEntry.customOrder) && isNotEmpty(objectEntry.customOrder.newOrderPages) && page < objectEntry.customOrder.newOrderPages.length) { + for (const uuid of objectEntry.customOrder.newOrderPages[page].order) { + let fieldUpdate = objectEntry.fieldUpdates[uuid]; + if (isEmpty(fieldUpdate)) { + const identifiable = initialFields.find((object: Identifiable) => object.uuid === uuid); + fieldUpdate = {field: identifiable, changeType: undefined}; + } + fieldUpdates[uuid] = fieldUpdate; + } + } + return fieldUpdates; + })) + } + /** * Method to check if a specific field is currently editable in the store * @param url The URL of the page on which the field resides @@ -199,6 +260,47 @@ export class ObjectUpdatesService { this.saveFieldUpdate(url, field, FieldChangeType.UPDATE); } + /** + * Dispatches a MoveFieldUpdateAction + * @param url The page's URL for which the changes are saved + * @param from The index of the object to move + * @param to The index to move the object to + * @param fromPage The page to move the object from + * @param toPage The page to move the object to + * @param field Optional field to add to the fieldUpdates list (useful if we want to track updates across multiple pages) + */ + saveMoveFieldUpdate(url: string, from: number, to: number, fromPage = 0, toPage = 0, field?: Identifiable) { + this.store.dispatch(new MoveFieldUpdateAction(url, from, to, fromPage, toPage, field)); + } + + /** + * Check whether the virtual metadata of a given item is selected to be saved as real metadata + * @param url The URL of the page on which the field resides + * @param relationship The id of the relationship for which to check whether the virtual metadata is selected to be + * saved as real metadata + * @param item The id of the item for which to check whether the virtual metadata is selected to be + * saved as real metadata + */ + isSelectedVirtualMetadata(url: string, relationship: string, item: string): Observable { + + return this.store + .pipe( + select(virtualMetadataSourceSelector(url, relationship)), + map((virtualMetadataSource) => virtualMetadataSource && virtualMetadataSource[item]), + ); + } + + /** + * Method to dispatch a SelectVirtualMetadataAction to the store + * @param url The page's URL for which the changes are saved + * @param relationship the relationship for which virtual metadata is selected + * @param uuid the selection identifier, can either be the item uuid or the relationship type uuid + * @param selected whether or not to select the virtual metadata to be saved + */ + setSelectedVirtualMetadata(url: string, relationship: string, uuid: string, selected: boolean) { + this.store.dispatch(new SelectVirtualMetadataAction(url, relationship, uuid, selected)); + } + /** * Dispatches a SetEditableFieldUpdateAction to the store to set a field's editable state * @param url The URL of the page on which the field resides @@ -228,6 +330,15 @@ export class ObjectUpdatesService { this.store.dispatch(new DiscardObjectUpdatesAction(url, undoNotification)); } + /** + * Method to dispatch a DiscardObjectUpdatesAction to the store with discardAll set to true + * @param url The page's URL for which the changes should be discarded + * @param undoNotification The notification which is should possibly be canceled + */ + discardAllFieldUpdates(url: string, undoNotification: INotification) { + this.store.dispatch(new DiscardObjectUpdatesAction(url, undoNotification, true)); + } + /** * Method to dispatch an ReinstateObjectUpdatesAction to the store * @param url The page's URL for which the changes should be reinstated @@ -276,7 +387,7 @@ export class ObjectUpdatesService { * @param url The page's url to check for in the store */ hasUpdates(url: string): Observable { - return this.getObjectEntry(url).pipe(map((objectEntry) => hasValue(objectEntry) && isNotEmpty(objectEntry.fieldUpdates))); + return this.getObjectEntry(url).pipe(map((objectEntry) => hasValue(objectEntry) && (isNotEmpty(objectEntry.fieldUpdates) || objectEntry.customOrder.changed))); } /** @@ -294,4 +405,19 @@ export class ObjectUpdatesService { getLastModified(url: string): Observable { return this.getObjectEntry(url).pipe(map((entry: ObjectUpdatesEntry) => entry.lastModified)); } + + /** + * Get move operations based on the custom order + * @param url The page's url + */ + getMoveOperations(url: string): Observable { + return this.getObjectEntry(url).pipe( + map((objectEntry) => objectEntry.customOrder), + map((customOrder) => this.comparator.diff( + flatten(customOrder.initialOrderPages.map((orderPage: OrderPage) => orderPage.order)), + flatten(customOrder.newOrderPages.map((orderPage: OrderPage) => orderPage.order))) + ) + ); + } + } diff --git a/src/app/core/data/paginated-list.ts b/src/app/core/data/paginated-list.ts index b9de67a34d..9f05ca7889 100644 --- a/src/app/core/data/paginated-list.ts +++ b/src/app/core/data/paginated-list.ts @@ -56,14 +56,14 @@ export class PaginatedList { } set first(first: string) { - this.pageInfo.first = first; + this.pageInfo._links.first = { href: first }; } get prev(): string { return this.pageInfo.prev; } set prev(prev: string) { - this.pageInfo.prev = prev; + this.pageInfo._links.prev = { href: prev }; } get next(): string { @@ -71,7 +71,7 @@ export class PaginatedList { } set next(next: string) { - this.pageInfo.next = next; + this.pageInfo._links.next = { href: next }; } get last(): string { @@ -79,7 +79,7 @@ export class PaginatedList { } set last(last: string) { - this.pageInfo.last = last; + this.pageInfo._links.last = { href: last }; } get self(): string { @@ -87,7 +87,7 @@ export class PaginatedList { } set self(self: string) { - this.pageInfo.self = self; + this.pageInfo._links.self = { href: self }; } protected getPageLength() { diff --git a/src/app/core/data/registry-bitstreamformats-response-parsing.service.ts b/src/app/core/data/registry-bitstreamformats-response-parsing.service.ts index 899fee4d1e..1cbcf358e3 100644 --- a/src/app/core/data/registry-bitstreamformats-response-parsing.service.ts +++ b/src/app/core/data/registry-bitstreamformats-response-parsing.service.ts @@ -1,11 +1,11 @@ +import { Injectable } from '@angular/core'; import { RegistryBitstreamformatsSuccessResponse, RestResponse } from '../cache/response.models'; -import { RegistryBitstreamformatsResponse } from '../registry/registry-bitstreamformats-response.model'; -import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { RestRequest } from './request.models'; +import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; +import { RegistryBitstreamformatsResponse } from '../registry/registry-bitstreamformats-response.model'; import { DSOResponseParsingService } from './dso-response-parsing.service'; import { ResponseParsingService } from './parsing.service'; -import { Injectable } from '@angular/core'; +import { RestRequest } from './request.models'; @Injectable() export class RegistryBitstreamformatsResponseParsingService implements ResponseParsingService { @@ -18,7 +18,7 @@ export class RegistryBitstreamformatsResponseParsingService implements ResponseP const bitstreamformats = payload._embedded.bitstreamformats; payload.bitstreamformats = bitstreamformats; - const deserialized = new DSpaceRESTv2Serializer(RegistryBitstreamformatsResponse).deserialize(payload); + const deserialized = new DSpaceSerializer(RegistryBitstreamformatsResponse).deserialize(payload); return new RegistryBitstreamformatsSuccessResponse(deserialized, data.statusCode, data.statusText, this.dsoParser.processPageInfo(data.payload.page)); } diff --git a/src/app/core/data/registry-metadatafields-response-parsing.service.ts b/src/app/core/data/registry-metadatafields-response-parsing.service.ts index a4bed3240e..cf9484c4c4 100644 --- a/src/app/core/data/registry-metadatafields-response-parsing.service.ts +++ b/src/app/core/data/registry-metadatafields-response-parsing.service.ts @@ -1,15 +1,12 @@ -import { - RegistryMetadatafieldsSuccessResponse, - RestResponse -} from '../cache/response.models'; -import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { RestRequest } from './request.models'; -import { ResponseParsingService } from './parsing.service'; -import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; -import { DSOResponseParsingService } from './dso-response-parsing.service'; import { Injectable } from '@angular/core'; -import { RegistryMetadatafieldsResponse } from '../registry/registry-metadatafields-response.model'; import { hasValue } from '../../shared/empty.util'; +import { RegistryMetadatafieldsSuccessResponse, RestResponse } from '../cache/response.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; +import { RegistryMetadatafieldsResponse } from '../registry/registry-metadatafields-response.model'; +import { DSOResponseParsingService } from './dso-response-parsing.service'; +import { ResponseParsingService } from './parsing.service'; +import { RestRequest } from './request.models'; @Injectable() export class RegistryMetadatafieldsResponseParsingService implements ResponseParsingService { @@ -30,7 +27,7 @@ export class RegistryMetadatafieldsResponseParsingService implements ResponsePar payload.metadatafields = metadatafields; - const deserialized = new DSpaceRESTv2Serializer(RegistryMetadatafieldsResponse).deserialize(payload); + const deserialized = new DSpaceSerializer(RegistryMetadatafieldsResponse).deserialize(payload); return new RegistryMetadatafieldsSuccessResponse(deserialized, data.statusCode, data.statusText, this.dsoParser.processPageInfo(data.payload)); } diff --git a/src/app/core/data/registry-metadataschemas-response-parsing.service.ts b/src/app/core/data/registry-metadataschemas-response-parsing.service.ts index d19b334131..416ed19dc2 100644 --- a/src/app/core/data/registry-metadataschemas-response-parsing.service.ts +++ b/src/app/core/data/registry-metadataschemas-response-parsing.service.ts @@ -1,12 +1,12 @@ -import { RegistryMetadataschemasSuccessResponse, RestResponse } from '../cache/response.models'; -import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { RestRequest } from './request.models'; -import { ResponseParsingService } from './parsing.service'; -import { RegistryMetadataschemasResponse } from '../registry/registry-metadataschemas-response.model'; -import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; -import { DSOResponseParsingService } from './dso-response-parsing.service'; import { Injectable } from '@angular/core'; import { hasValue } from '../../shared/empty.util'; +import { RegistryMetadataschemasSuccessResponse, RestResponse } from '../cache/response.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; +import { RegistryMetadataschemasResponse } from '../registry/registry-metadataschemas-response.model'; +import { DSOResponseParsingService } from './dso-response-parsing.service'; +import { ResponseParsingService } from './parsing.service'; +import { RestRequest } from './request.models'; @Injectable() export class RegistryMetadataschemasResponseParsingService implements ResponseParsingService { @@ -22,7 +22,7 @@ export class RegistryMetadataschemasResponseParsingService implements ResponsePa } payload.metadataschemas = metadataschemas; - const deserialized = new DSpaceRESTv2Serializer(RegistryMetadataschemasResponse).deserialize(payload); + const deserialized = new DSpaceSerializer(RegistryMetadataschemasResponse).deserialize(payload); return new RegistryMetadataschemasSuccessResponse(deserialized, data.statusCode, data.statusText, this.dsoParser.processPageInfo(data.payload)); } diff --git a/src/app/core/data/relationship-type.service.spec.ts b/src/app/core/data/relationship-type.service.spec.ts index 118baf8738..0a86b4bc61 100644 --- a/src/app/core/data/relationship-type.service.spec.ts +++ b/src/app/core/data/relationship-type.service.spec.ts @@ -1,14 +1,15 @@ -import { RequestService } from './request.service'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; -import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service'; -import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; -import { getMockRequestService } from '../../shared/mocks/mock-request.service'; -import { PaginatedList } from './paginated-list'; -import { PageInfo } from '../shared/page-info.model'; -import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils'; -import { RelationshipTypeService } from './relationship-type.service'; import { of as observableOf } from 'rxjs'; +import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service'; +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils'; +import { ObjectCacheService } from '../cache/object-cache.service'; import { ItemType } from '../shared/item-relationships/item-type.model'; +import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; +import { PageInfo } from '../shared/page-info.model'; +import { PaginatedList } from './paginated-list'; +import { RelationshipTypeService } from './relationship-type.service'; +import { RequestService } from './request.service'; describe('RelationshipTypeService', () => { let service: RelationshipTypeService; @@ -25,8 +26,10 @@ describe('RelationshipTypeService', () => { let relationshipType1; let relationshipType2; + let itemService; let buildList; let rdbService; + let objectCache; function init() { restEndpointURL = 'https://rest.api/relationshiptypes'; @@ -58,13 +61,29 @@ describe('RelationshipTypeService', () => { buildList = createSuccessfulRemoteDataObject(new PaginatedList(new PageInfo(), [relationshipType1, relationshipType2])); rdbService = getMockRemoteDataBuildService(undefined, observableOf(buildList)); + objectCache = Object.assign({ + /* tslint:disable:no-empty */ + remove: () => { + }, + hasBySelfLinkObservable: () => observableOf(false) + /* tslint:enable:no-empty */ + }) as ObjectCacheService; + + itemService = undefined; } function initTestService() { return new RelationshipTypeService( + itemService, requestService, + rdbService, + null, halService, - rdbService + objectCache, + null, + null, + null, + null ); } diff --git a/src/app/core/data/relationship-type.service.ts b/src/app/core/data/relationship-type.service.ts index 7978373b08..eefe663209 100644 --- a/src/app/core/data/relationship-type.service.ts +++ b/src/app/core/data/relationship-type.service.ts @@ -1,28 +1,49 @@ +import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { RequestService } from './request.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { filter, find, map, switchMap } from 'rxjs/operators'; -import { configureRequest, getSucceededRemoteData } from '../shared/operators'; -import { Observable } from 'rxjs/internal/Observable'; -import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; -import { RemoteData } from './remote-data'; -import { PaginatedList } from './paginated-list'; +import { Store } from '@ngrx/store'; import { combineLatest as observableCombineLatest } from 'rxjs'; -import { ItemType } from '../shared/item-relationships/item-type.model'; +import { Observable } from 'rxjs/internal/Observable'; +import { filter, find, map, switchMap } from 'rxjs/operators'; +import { AppState } from '../../app.reducer'; import { isNotUndefined } from '../../shared/empty.util'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { followLink } 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'; +import { CoreState } from '../core.reducers'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ItemType } from '../shared/item-relationships/item-type.model'; +import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; +import { RELATIONSHIP_TYPE } from '../shared/item-relationships/relationship-type.resource-type'; +import { configureRequest, getSucceededRemoteData } from '../shared/operators'; +import { DataService } from './data.service'; +import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { ItemDataService } from './item-data.service'; +import { PaginatedList } from './paginated-list'; +import { RemoteData } from './remote-data'; import { FindListOptions, FindListRequest } from './request.models'; +import { RequestService } from './request.service'; /** - * The service handling all relationship requests + * The service handling all relationship type requests */ @Injectable() -export class RelationshipTypeService { +@dataService(RELATIONSHIP_TYPE) +export class RelationshipTypeService extends DataService { protected linkPath = 'relationshiptypes'; - constructor(protected requestService: RequestService, + constructor(protected itemService: ItemDataService, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, protected halService: HALEndpointService, - protected rdbService: RemoteDataBuildService) { + protected objectCache: ObjectCacheService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DefaultChangeAnalyzer, + protected appStore: Store) { + super() } /** @@ -41,7 +62,7 @@ export class RelationshipTypeService { .pipe( map((endpointURL: string) => new FindListRequest(this.requestService.generateRequestId(), endpointURL, options)), configureRequest(this.requestService), - switchMap(() => this.rdbService.buildList(link$)) + switchMap(() => this.rdbService.buildList(link$, followLink('leftType'), followLink('rightType'))) ) as Observable>>; } diff --git a/src/app/core/data/relationship.service.spec.ts b/src/app/core/data/relationship.service.spec.ts index 9287935f59..247dce1619 100644 --- a/src/app/core/data/relationship.service.spec.ts +++ b/src/app/core/data/relationship.service.spec.ts @@ -1,26 +1,28 @@ -import { RelationshipService } from './relationship.service'; -import { RequestService } from './request.service'; -import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; -import { getMockRemoteDataBuildService } from '../../shared/mocks/mock-remote-data-build.service'; +import { Observable } from 'rxjs/internal/Observable'; import { of as observableOf } from 'rxjs/internal/observable/of'; -import { RequestEntry } from './request.reducer'; +import * as ItemRelationshipsUtils from '../../+item-page/simple/item-types/shared/item-relationships-utils'; +import { getMockRemoteDataBuildServiceHrefMap } from '../../shared/mocks/mock-remote-data-build.service'; +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; +import { createSuccessfulRemoteDataObject$, spyOnOperator } from '../../shared/testing/utils'; +import { followLink } from '../../shared/utils/follow-link-config.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; import { Relationship } from '../shared/item-relationships/relationship.model'; -import { RemoteData } from './remote-data'; -import { getMockRequestService } from '../../shared/mocks/mock-request.service'; import { Item } from '../shared/item.model'; -import { PaginatedList } from './paginated-list'; import { PageInfo } from '../shared/page-info.model'; -import { DeleteRequest } from './request.models'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { Observable } from 'rxjs/internal/Observable'; -import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils'; +import { PaginatedList } from './paginated-list'; +import { RelationshipService } from './relationship.service'; +import { RemoteData } from './remote-data'; +import { DeleteRequest, FindListOptions } from './request.models'; +import { RequestEntry } from './request.reducer'; +import { RequestService } from './request.service'; describe('RelationshipService', () => { let service: RelationshipService; let requestService: RequestService; - const restEndpointURL = 'https://rest.api/'; + const restEndpointURL = 'https://rest.api/core'; const relationshipsEndpointURL = `${restEndpointURL}/relationships`; const halService: any = new HALEndpointServiceStub(restEndpointURL); @@ -31,36 +33,68 @@ describe('RelationshipService', () => { rightwardType: 'isPublicationOfAuthor' }); + const ri1SelfLink = restEndpointURL + '/author1'; + const ri2SelfLink = restEndpointURL + '/author2'; + const itemSelfLink = restEndpointURL + '/publication'; + const relationship1 = Object.assign(new Relationship(), { - self: relationshipsEndpointURL + '/2', + _links: { + self: { + href: relationshipsEndpointURL + '/2' + }, + leftItem: { + href: ri1SelfLink + }, + rightItem: { + href: itemSelfLink + } + }, id: '2', uuid: '2', relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) }); const relationship2 = Object.assign(new Relationship(), { - self: relationshipsEndpointURL + '/3', + _links: { + self: { + href: relationshipsEndpointURL + '/3' + }, + leftItem: { + href: ri2SelfLink + }, + rightItem: { + href: itemSelfLink + }, + }, id: '3', uuid: '3', relationshipType: observableOf(new RemoteData(false, false, true, undefined, relationshipType)) }); - const relationships = [ relationship1, relationship2 ]; - - const item = Object.assign(new Item(), { - self: 'fake-item-url/publication', + const relationships = [relationship1, relationship2]; const item = Object.assign(new Item(), { id: 'publication', uuid: 'publication', - relationships: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(new PageInfo(), relationships))) + relationships: observableOf(new RemoteData(false, false, true, undefined, new PaginatedList(new PageInfo(), relationships))), + _links: { + relationships: { href: restEndpointURL + '/publication/relationships' }, + self: { href: itemSelfLink } + } }); const relatedItem1 = Object.assign(new Item(), { id: 'author1', - uuid: 'author1' + uuid: 'author1', + _links: { + self: { href: ri1SelfLink } + } }); const relatedItem2 = Object.assign(new Item(), { id: 'author2', - uuid: 'author2' + uuid: 'author2', + _links: { + self: { href: ri2SelfLink } + } }); + relationship1.leftItem = getRemotedataObservable(relatedItem1); relationship1.rightItem = getRemotedataObservable(item); relationship2.leftItem = getRemotedataObservable(relatedItem2); @@ -68,10 +102,12 @@ describe('RelationshipService', () => { const relatedItems = [relatedItem1, relatedItem2]; const buildList$ = createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [relatedItems])); - const rdbService = getMockRemoteDataBuildService(undefined, buildList$); + const relationships$ = createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [relationships])); + const rdbService = getMockRemoteDataBuildServiceHrefMap(undefined, {'href': buildList$, 'https://rest.api/core/publication/relationships': relationships$}); const objectCache = Object.assign({ /* tslint:disable:no-empty */ - remove: () => {}, + remove: () => { + }, hasBySelfLinkObservable: () => observableOf(false) /* tslint:enable:no-empty */ }) as ObjectCacheService; @@ -87,7 +123,6 @@ describe('RelationshipService', () => { requestService, rdbService, null, - null, halService, objectCache, null, @@ -112,42 +147,113 @@ describe('RelationshipService', () => { beforeEach(() => { spyOn(service, 'findById').and.returnValue(getRemotedataObservable(relationship1)); spyOn(objectCache, 'remove'); - service.deleteRelationship(relationships[0].uuid).subscribe(); + service.deleteRelationship(relationships[0].uuid, 'right').subscribe(); }); it('should send a DeleteRequest', () => { - const expected = new DeleteRequest(requestService.generateRequestId(), relationshipsEndpointURL + '/' + relationship1.uuid); + const expected = new DeleteRequest(requestService.generateRequestId(), relationshipsEndpointURL + '/' + relationship1.uuid + '?copyVirtualMetadata=right'); expect(requestService.configure).toHaveBeenCalledWith(expected); }); - it('should clear the related items their cache', () => { - expect(objectCache.remove).toHaveBeenCalledWith(relatedItem1.self); - expect(objectCache.remove).toHaveBeenCalledWith(item.self); - expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(relatedItem1.uuid); - expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(item.uuid); + it('should clear the cache of the related items', () => { + expect(objectCache.remove).toHaveBeenCalledWith(relatedItem1._links.self.href); + expect(objectCache.remove).toHaveBeenCalledWith(item._links.self.href); + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(relatedItem1.self); + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(item.self); }); }); describe('getItemRelationshipsArray', () => { - it('should return the item\'s relationships in the form of an array', () => { + it('should return the item\'s relationships in the form of an array', (done) => { service.getItemRelationshipsArray(item).subscribe((result) => { - expect(result).toEqual(relationships); + result.forEach((relResult: any) => { + expect(relResult).toEqual(relationships); + }); + done(); }); }); }); describe('getRelatedItems', () => { - it('should return the related items', () => { - service.getRelatedItems(item).subscribe((result) => { - expect(result).toEqual(relatedItems); + let mockItem; + + beforeEach(() => { + mockItem = { uuid: 'someid' } as Item; + + spyOn(service, 'getItemRelationshipsArray').and.returnValue(observableOf(relationships)); + + spyOnOperator(ItemRelationshipsUtils, 'relationsToItems').and.returnValue((v) => v); + }); + + it('should call getItemRelationshipsArray with the correct params', (done) => { + service.getRelatedItems(mockItem).subscribe(() => { + expect(service.getItemRelationshipsArray).toHaveBeenCalledWith( + mockItem, + followLink('leftItem'), + followLink('rightItem'), + followLink('relationshipType') + ); + done(); + }); + }); + + it('should use the relationsToItems operator', (done) => { + service.getRelatedItems(mockItem).subscribe(() => { + expect(ItemRelationshipsUtils.relationsToItems).toHaveBeenCalledWith(mockItem.uuid); + done(); }); }); }); describe('getRelatedItemsByLabel', () => { - it('should return the related items by label', () => { - service.getRelatedItemsByLabel(item, relationshipType.rightwardType).subscribe((result) => { - expect(result.payload.page).toEqual(relatedItems); + let relationsList; + let mockItem; + let mockLabel; + let mockOptions; + + beforeEach(() => { + relationsList = new PaginatedList(new PageInfo({ + elementsPerPage: relationships.length, + totalElements: relationships.length, + currentPage: 1, + totalPages: 1 + }), relationships); + mockItem = { uuid: 'someid' } as Item; + mockLabel = 'label'; + mockOptions = { label: 'options' } as FindListOptions; + + const rd$ = createSuccessfulRemoteDataObject$(relationsList); + spyOn(service, 'getItemRelationshipsByLabel').and.returnValue(rd$); + + spyOnOperator(ItemRelationshipsUtils, 'paginatedRelationsToItems').and.returnValue((v) => v); + }); + + it('should call getItemRelationshipsByLabel with the correct params', (done) => { + service.getRelatedItemsByLabel( + mockItem, + mockLabel, + mockOptions + ).subscribe((result) => { + expect(service.getItemRelationshipsByLabel).toHaveBeenCalledWith( + mockItem, + mockLabel, + mockOptions, + followLink('leftItem'), + followLink('rightItem'), + followLink('relationshipType') + ); + done(); + }); + }); + + it('should use the paginatedRelationsToItems operator', (done) => { + service.getRelatedItemsByLabel( + mockItem, + mockLabel, + mockOptions + ).subscribe((result) => { + expect(ItemRelationshipsUtils.paginatedRelationsToItems).toHaveBeenCalledWith(mockItem.uuid); + done(); }); }); }) diff --git a/src/app/core/data/relationship.service.ts b/src/app/core/data/relationship.service.ts index d6993ebcee..4dde567c99 100644 --- a/src/app/core/data/relationship.service.ts +++ b/src/app/core/data/relationship.service.ts @@ -2,34 +2,48 @@ import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { MemoizedSelector, select, Store } from '@ngrx/store'; import { combineLatest, combineLatest as observableCombineLatest } from 'rxjs'; +import { Observable } from 'rxjs/internal/Observable'; import { distinctUntilChanged, filter, map, mergeMap, startWith, switchMap, take, tap } from 'rxjs/operators'; -import { compareArraysUsingIds, paginatedRelationsToItems, relationsToItems } from '../../+item-page/simple/item-types/shared/item-relationships-utils'; +import { + compareArraysUsingIds, + paginatedRelationsToItems, + relationsToItems +} from '../../+item-page/simple/item-types/shared/item-relationships-utils'; import { AppState, keySelector } from '../../app.reducer'; import { hasValue, hasValueOperator, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util'; import { ReorderableRelationship } from '../../shared/form/builder/ds-dynamic-form-ui/existing-metadata-list-element/existing-metadata-list-element.component'; -import { RemoveNameVariantAction, SetNameVariantAction } from '../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.actions'; +import { + RemoveNameVariantAction, + SetNameVariantAction +} from '../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.actions'; import { NameVariantListState } from '../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/name-variant.reducer'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { followLink, 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 { configureRequest, getRemoteDataPayload, getResponseFromEntry, getSucceededRemoteData } from '../shared/operators'; import { SearchParam } from '../cache/models/search-param.model'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { DeleteRequest, FindListOptions, PostRequest, RestRequest } from './request.models'; import { RestResponse } from '../cache/response.models'; import { CoreState } from '../core.reducers'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RelationshipType } from '../shared/item-relationships/relationship-type.model'; -import { RemoteData, RemoteDataState } from './remote-data'; -import { PaginatedList } from './paginated-list'; -import { ItemDataService } from './item-data.service'; import { Relationship } from '../shared/item-relationships/relationship.model'; +import { RELATIONSHIP } from '../shared/item-relationships/relationship.resource-type'; import { Item } from '../shared/item.model'; +import { + configureRequest, + getRemoteDataPayload, + getResponseFromEntry, + getSucceededRemoteData +} from '../shared/operators'; import { DataService } from './data.service'; import { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { ItemDataService } from './item-data.service'; +import { PaginatedList } from './paginated-list'; +import { RemoteData, RemoteDataState } from './remote-data'; +import { DeleteRequest, FindListOptions, PostRequest, RestRequest } from './request.models'; import { RequestService } from './request.service'; -import { Observable } from 'rxjs/internal/Observable'; const relationshipListsStateSelector = (state: AppState) => state.relationshipLists; @@ -45,14 +59,13 @@ const relationshipStateSelector = (listID: string, itemID: string): MemoizedSele * The service handling all relationship requests */ @Injectable() +@dataService(RELATIONSHIP) export class RelationshipService extends DataService { protected linkPath = 'relationships'; - protected forceBypassCache = false; constructor(protected itemService: ItemDataService, protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected halService: HALEndpointService, protected objectCache: ObjectCacheService, @@ -63,10 +76,6 @@ export class RelationshipService extends DataService { super(); } - getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { - return this.halService.getEndpoint(linkPath); - } - /** * Get the endpoint for a relationship by ID * @param uuid @@ -81,15 +90,22 @@ export class RelationshipService extends DataService { * Send a delete request for a relationship by ID * @param id */ - deleteRelationship(id: string): Observable { + deleteRelationship(id: string, copyVirtualMetadata: string): Observable { return this.getRelationshipEndpoint(id).pipe( isNotEmptyOperator(), take(1), - map((endpointURL: string) => new DeleteRequest(this.requestService.generateRequestId(), endpointURL)), + distinctUntilChanged(), + map((endpointURL: string) => + new DeleteRequest(this.requestService.generateRequestId(), endpointURL + '?copyVirtualMetadata=' + copyVirtualMetadata) + ), configureRequest(this.requestService), switchMap((restRequest: RestRequest) => this.requestService.getByUUID(restRequest.uuid)), getResponseFromEntry(), - tap(() => this.removeRelationshipItemsFromCacheByRelationship(id)) + switchMap((response) => + this.clearRelatedCache(id).pipe( + map(() => response), + ) + ), ); } @@ -146,29 +162,29 @@ export class RelationshipService extends DataService { * @param item The item to remove from the cache */ private removeRelationshipItemsFromCache(item) { - this.objectCache.remove(item.self); + this.objectCache.remove(item._links.self.href); this.requestService.removeByHrefSubstring(item.uuid); combineLatest( - this.objectCache.hasBySelfLinkObservable(item.self), + this.objectCache.hasBySelfLinkObservable(item._links.self.href), this.requestService.hasByHrefObservable(item.uuid) ).pipe( filter(([existsInOC, existsInRC]) => !existsInOC && !existsInRC), take(1), - switchMap(() => this.itemService.findByHref(item.self).pipe(take(1))) + switchMap(() => this.itemService.findByHref(item._links.self.href).pipe(take(1))) ).subscribe(); } /** - * Get an item its relationships in the form of an array + * Get an item's relationships in the form of an array * @param item */ - getItemRelationshipsArray(item: Item): Observable { - return item.relationships.pipe( + getItemRelationshipsArray(item: Item, ...linksToFollow: Array>): Observable { + return this.findAllByHref(item._links.relationships.href, undefined, ...linksToFollow).pipe( getSucceededRemoteData(), getRemoteDataPayload(), map((rels: PaginatedList) => rels.page), hasValueOperator(), - distinctUntilChanged(compareArraysUsingIds()) + distinctUntilChanged(compareArraysUsingIds()), ); } @@ -178,7 +194,7 @@ export class RelationshipService extends DataService { * @param item */ getRelationshipTypeLabelsByItem(item: Item): Observable { - return this.getItemRelationshipsArray(item).pipe( + return this.getItemRelationshipsArray(item, followLink('leftItem'), followLink('rightItem'), followLink('relationshipType')).pipe( switchMap((relationships: Relationship[]) => observableCombineLatest(relationships.map((relationship: Relationship) => this.getRelationshipTypeLabelByRelationshipAndItem(relationship, item)))), map((labels: string[]) => Array.from(new Set(labels))) ); @@ -207,7 +223,12 @@ export class RelationshipService extends DataService { * @param item */ getRelatedItems(item: Item): Observable { - return this.getItemRelationshipsArray(item).pipe( + return this.getItemRelationshipsArray( + item, + followLink('leftItem'), + followLink('rightItem'), + followLink('relationshipType') + ).pipe( relationsToItems(item.uuid) ); } @@ -220,17 +241,18 @@ export class RelationshipService extends DataService { * @param options */ getRelatedItemsByLabel(item: Item, label: string, options?: FindListOptions): Observable>> { - return this.getItemRelationshipsByLabel(item, label, options).pipe(paginatedRelationsToItems(item.uuid)); + return this.getItemRelationshipsByLabel(item, label, options, followLink('leftItem'), followLink('rightItem'), followLink('relationshipType')).pipe(paginatedRelationsToItems(item.uuid)); } /** - * Resolve a given item's relationships into related items, filtered by a relationship label - * and return the items as an array + * Resolve a given item's relationships by label + * This should move to the REST API. + * * @param item * @param label * @param options */ - getItemRelationshipsByLabel(item: Item, label: string, options?: FindListOptions): Observable>> { + getItemRelationshipsByLabel(item: Item, label: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { let findListOptions = new FindListOptions(); if (options) { findListOptions = Object.assign(new FindListOptions(), options); @@ -241,7 +263,7 @@ export class RelationshipService extends DataService { } else { findListOptions.searchParams = searchParams; } - return this.searchBy('byLabel', findListOptions); + return this.searchBy('byLabel', findListOptions, ...linksToFollow); } /** @@ -251,7 +273,7 @@ export class RelationshipService extends DataService { * @param uuids */ getRelationshipsByRelatedItemIds(item: Item, uuids: string[]): Observable { - return this.getItemRelationshipsArray(item).pipe( + return this.getItemRelationshipsArray(item, followLink('leftItem'), followLink('rightItem')).pipe( switchMap((relationships: Relationship[]) => { return observableCombineLatest(...relationships.map((relationship: Relationship) => { const isLeftItem$ = this.isItemInUUIDArray(relationship.leftItem, uuids); @@ -281,8 +303,8 @@ export class RelationshipService extends DataService { * @param item2 The second item in the relationship * @param label The rightward or leftward type of the relationship */ - getRelationshipByItemsAndLabel(item1: Item, item2: Item, label: string): Observable { - return this.getItemRelationshipsByLabel(item1, label) + getRelationshipByItemsAndLabel(item1: Item, item2: Item, label: string, options?: FindListOptions): Observable { + return this.getItemRelationshipsByLabel(item1, label, options, followLink('relationshipType'), followLink('leftItem'), followLink('rightItem')) .pipe( getSucceededRemoteData(), isNotEmptyOperator(), @@ -417,4 +439,19 @@ export class RelationshipService extends DataService { return update$; } + /** + * Clear object and request caches of the items related to a relationship (left and right items) + * @param uuid The uuid of the relationship for which to clear the related items from the cache + */ + clearRelatedCache(uuid: string): Observable { + return this.findById(uuid).pipe( + getSucceededRemoteData(), + map((rd: RemoteData) => { + this.objectCache.remove(rd.payload._links.leftItem.href); + this.objectCache.remove(rd.payload._links.rightItem.href); + this.requestService.removeByHrefSubstring(rd.payload._links.leftItem.href); + this.requestService.removeByHrefSubstring(rd.payload._links.rightItem.href); + }) + ); + } } 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.effects.ts b/src/app/core/data/request.effects.ts index 9ef85bfe8b..a9052aa8dc 100644 --- a/src/app/core/data/request.effects.ts +++ b/src/app/core/data/request.effects.ts @@ -1,12 +1,17 @@ -import { Observable, of as observableOf } from 'rxjs'; import { Inject, Injectable, Injector } from '@angular/core'; import { Actions, Effect, ofType } from '@ngrx/effects'; +import { Observable, of as observableOf } from 'rxjs'; +import { catchError, filter, flatMap, map, take } from 'rxjs/operators'; import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { StoreActionTypes } from '../../store.actions'; +import { getClassForType } from '../cache/builders/build-decorators'; +import { ErrorResponse, RestResponse } from '../cache/response.models'; import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; import { DSpaceRESTv2Service } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; import { RequestActionTypes, RequestCompleteAction, @@ -16,11 +21,6 @@ import { import { RequestError, RestRequest } from './request.models'; import { RequestEntry } from './request.reducer'; import { RequestService } from './request.service'; -import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; -import { catchError, filter, flatMap, map, take, tap } from 'rxjs/operators'; -import { ErrorResponse, RestResponse } from '../cache/response.models'; -import { StoreActionTypes } from '../../store.actions'; -import { getMapsToType } from '../cache/builders/build-decorators'; export const addToResponseCacheAndCompleteAction = (request: RestRequest, envConfig: GlobalConfig) => (source: Observable): Observable => @@ -45,7 +45,7 @@ export class RequestEffects { flatMap((request: RestRequest) => { let body; if (isNotEmpty(request.body)) { - const serializer = new DSpaceRESTv2Serializer(getMapsToType(request.body.type)); + const serializer = new DSpaceSerializer(getClassForType(request.body.type)); body = serializer.serialize(request.body); } return this.restApi.request(request.method, request.href, body, request.options).pipe( diff --git a/src/app/core/data/request.models.ts b/src/app/core/data/request.models.ts index f7546183ae..0655333502 100644 --- a/src/app/core/data/request.models.ts +++ b/src/app/core/data/request.models.ts @@ -18,6 +18,7 @@ import { MetadataschemaParsingService } from './metadataschema-parsing.service'; import { MetadatafieldParsingService } from './metadatafield-parsing.service'; import { URLCombiner } from '../url-combiner/url-combiner'; import { TaskResponseParsingService } from '../tasks/task-response-parsing.service'; +import { ContentSourceResponseParsingService } from './content-source-response-parsing.service'; import { MappedCollectionsReponseParsingService } from './mapped-collections-reponse-parsing.service'; /* tslint:disable:max-classes-per-file */ @@ -160,6 +161,8 @@ export class FindListRequest extends GetRequest { } export class EndpointMapRequest extends GetRequest { + public responseMsToLive = Number.MAX_SAFE_INTEGER; + constructor( uuid: string, href: string, @@ -227,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); } @@ -380,6 +385,26 @@ export class CreateRequest extends PostRequest { } } +export class ContentSourceRequest extends GetRequest { + constructor(uuid: string, href: string) { + super(uuid, href); + } + + getResponseParser(): GenericConstructor { + return ContentSourceResponseParsingService; + } +} + +export class UpdateContentSourceRequest extends PutRequest { + constructor(uuid: string, href: string, public body?: any, public options?: HttpOptions) { + super(uuid, href, body, options); + } + + getResponseParser(): GenericConstructor { + return ContentSourceResponseParsingService; + } +} + /** * Request to delete an object based on its identifier */ diff --git a/src/app/core/data/request.reducer.spec.ts b/src/app/core/data/request.reducer.spec.ts index 65a4ddba17..d32fe348b5 100644 --- a/src/app/core/data/request.reducer.spec.ts +++ b/src/app/core/data/request.reducer.spec.ts @@ -1,13 +1,15 @@ import * as deepFreeze from 'deep-freeze'; - -import { requestReducer, RequestState } from './request.reducer'; +import { RestResponse } from '../cache/response.models'; import { RequestCompleteAction, RequestConfigureAction, - RequestExecuteAction, RequestRemoveAction, ResetResponseTimestampsAction + RequestExecuteAction, + RequestRemoveAction, + ResetResponseTimestampsAction } from './request.actions'; import { GetRequest } from './request.models'; -import { RestResponse } from '../cache/response.models'; + +import { requestReducer, RequestState } from './request.reducer'; const response = new RestResponse(true, 200, 'OK'); class NullAction extends RequestCompleteAction { diff --git a/src/app/core/data/request.service.spec.ts b/src/app/core/data/request.service.spec.ts index 01560380c2..cfb3611fc6 100644 --- a/src/app/core/data/request.service.spec.ts +++ b/src/app/core/data/request.service.spec.ts @@ -2,6 +2,7 @@ import * as ngrx from '@ngrx/store'; import { ActionsSubject, Store } from '@ngrx/store'; import { cold, getTestScheduler, hot } from 'jasmine-marbles'; import { BehaviorSubject, EMPTY, of as observableOf } from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; import { getMockObjectCacheService } from '../../shared/mocks/mock-object-cache.service'; import { defaultUUID, getMockUUIDService } from '../../shared/mocks/mock-uuid.service'; @@ -19,9 +20,9 @@ import { PutRequest, RestRequest } from './request.models'; -import { RequestService } from './request.service'; -import { TestScheduler } from 'rxjs/testing'; import { RequestEntry } from './request.reducer'; +import { RequestService } from './request.service'; +import { parseJsonSchemaToCommandDescription } from '@angular/cli/utilities/json-schema'; describe('RequestService', () => { let scheduler: TestScheduler; @@ -139,13 +140,21 @@ describe('RequestService', () => { describe('getByUUID', () => { describe('if the request with the specified UUID exists in the store', () => { beforeEach(() => { + let callCounter = 0; + const responses = [ + cold('a', { // A direct hit in the request cache + a: { + completed: true + } + }), + cold('b', { b: undefined }), // No hit in the index + cold('c', { c: undefined }) // So no mapped hit in the request cache + ]; selectSpy.and.callFake(() => { return () => { - return () => hot('a', { - a: { - completed: true - } - }); + const response = responses[callCounter]; + callCounter++; + return () => response; }; }); }); @@ -162,11 +171,19 @@ describe('RequestService', () => { }); }); - describe('if the request with the specified UUID doesn\'t exist in the store', () => { + describe(`if the request with the specified UUID doesn't exist in the store `, () => { beforeEach(() => { + let callCounter = 0; + const responses = [ + cold('a', { a: undefined }), // No direct hit in the request cache + cold('b', { b: undefined }), // No hit in the index + cold('c', { c: undefined }), // So no mapped hit in the request cache + ]; selectSpy.and.callFake(() => { return () => { - return () => hot('a', { a: undefined }); + const response = responses[callCounter]; + callCounter++; + return () => response; }; }); }); @@ -174,11 +191,43 @@ describe('RequestService', () => { it('should return an Observable of undefined', () => { const result = service.getByUUID(testUUID); - scheduler.expectObservable(result).toBe('b', { b: undefined }); + scheduler.expectObservable(result).toBe('a', { a: undefined }); }); }); - }); + describe(`if the request with the specified UUID wasn't sent, because it was already cached`, () => { + beforeEach(() => { + let callCounter = 0; + const responses = [ + cold('a', { a: undefined }), // No direct hit in the request cache with that UUID + cold('b', { b: 'otherRequestUUID' }), // A hit in the index, which returns the uuid of the cached request + cold('c', { // the call to retrieve the cached request using the UUID from the index + c: { + completed: true + } + }) + ]; + selectSpy.and.callFake(() => { + return () => { + const response = responses[callCounter]; + callCounter++; + return () => response; + }; + }); + }); + + it(`it should return the cached request`, () => { + const result = service.getByUUID(testUUID); + + scheduler.expectObservable(result).toBe('c', { + c: { + completed: true + } + }); + }); + }); + + }); describe('getByHref', () => { describe('when the request with the specified href exists in the store', () => { diff --git a/src/app/core/data/request.service.ts b/src/app/core/data/request.service.ts index b811a75549..105d84cf4a 100644 --- a/src/app/core/data/request.service.ts +++ b/src/app/core/data/request.service.ts @@ -2,10 +2,10 @@ import { Injectable } from '@angular/core'; 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 { Observable, combineLatest as observableCombineLatest } from 'rxjs'; +import { filter, map, mergeMap, take, switchMap, startWith } from 'rxjs/operators'; import { cloneDeep, remove } from 'lodash'; -import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { hasValue, isEmpty, isNotEmpty, hasValueOperator } from '../../shared/empty.util'; import { CacheableObject } from '../cache/object-cache.reducer'; import { ObjectCacheService } from '../cache/object-cache.service'; import { CoreState } from '../core.reducers'; @@ -71,7 +71,9 @@ const getUuidsFromHrefSubstring = (state: IndexState, href: string): string[] => /** * A service to interact with the request state in the store */ -@Injectable() +@Injectable({ + providedIn: 'root' +}) export class RequestService { private requestsOnTheirWayToTheStore: string[] = []; @@ -108,15 +110,19 @@ export class RequestService { * Retrieve a RequestEntry based on their uuid */ getByUUID(uuid: string): Observable { - return observableRace( - this.store.pipe(select(entryFromUUIDSelector(uuid))), + return observableCombineLatest([ + this.store.pipe( + select(entryFromUUIDSelector(uuid)) + ), this.store.pipe( select(originalRequestUUIDFromRequestUUIDSelector(uuid)), - mergeMap((originalUUID) => { - return this.store.pipe(select(entryFromUUIDSelector(originalUUID))) + switchMap((originalUUID) => { + return this.store.pipe(select(entryFromUUIDSelector(originalUUID))) }, - )) - ).pipe( + ), + ), + ]).pipe( + map((entries: RequestEntry[]) => entries.find((entry: RequestEntry) => hasValue(entry))), map((entry: RequestEntry) => { // Headers break after being retrieved from the store (because of lazy initialization) // Combining them with a new object fixes this issue @@ -135,7 +141,13 @@ export class RequestService { getByHref(href: string): Observable { return this.store.pipe( select(uuidFromHrefSelector(href)), - mergeMap((uuid: string) => this.getByUUID(uuid)) + mergeMap((uuid: string) => { + if (isNotEmpty(uuid)) { + return this.getByUUID(uuid); + } else { + return [undefined]; + } + }) ); } @@ -216,7 +228,7 @@ export class RequestService { * @param {GetRequest} request The request to check * @returns {boolean} True if the request is cached or still pending */ - private isCachedOrPending(request: GetRequest): boolean { + public isCachedOrPending(request: GetRequest): boolean { const inReqCache = this.hasByHref(request.href); const inObjCache = this.objectCache.hasBySelfLink(request.href); const isCached = inReqCache || inObjCache; diff --git a/src/app/core/data/resource-policy.service.spec.ts b/src/app/core/data/resource-policy.service.spec.ts index 1a02171be3..abed805ca3 100644 --- a/src/app/core/data/resource-policy.service.spec.ts +++ b/src/app/core/data/resource-policy.service.spec.ts @@ -1,15 +1,13 @@ +import { HttpClient } from '@angular/common/http'; import { cold, getTestScheduler } from 'jasmine-marbles'; import { TestScheduler } from 'rxjs/testing'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { ResourcePolicy } from '../shared/resource-policy.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { GetRequest } from './request.models'; +import { ResourcePolicy } from '../shared/resource-policy.model'; import { RequestService } from './request.service'; import { ResourcePolicyService } from './resource-policy.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; describe('ResourcePolicyService', () => { let scheduler: TestScheduler; @@ -42,26 +40,26 @@ describe('ResourcePolicyService', () => { const notificationsService = {} as NotificationsService; const http = {} as HttpClient; const comparator = {} as any; - const dataBuildService = {} as NormalizedObjectBuildService; service = new ResourcePolicyService( requestService, rdbService, - dataBuildService, objectCache, halService, notificationsService, http, comparator - ) + ); + + spyOn((service as any).dataService, 'findByHref').and.callThrough(); }); describe('findByHref', () => { - it('should configure the proper GetRequest', () => { + it('should proxy the call to dataservice.findByHref', () => { scheduler.schedule(() => service.findByHref(requestURL)); scheduler.flush(); - expect(requestService.configure).toHaveBeenCalledWith(new GetRequest(requestUUID, requestURL, null)); + expect((service as any).dataService.findByHref).toHaveBeenCalledWith(requestURL); }); it('should return a RemoteData for the object with the given URL', () => { diff --git a/src/app/core/data/resource-policy.service.ts b/src/app/core/data/resource-policy.service.ts index 017e5cf5ee..f66032925e 100644 --- a/src/app/core/data/resource-policy.service.ts +++ b/src/app/core/data/resource-policy.service.ts @@ -3,10 +3,13 @@ import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { dataService } from '../cache/builders/build-decorators'; import { DataService } from '../data/data.service'; import { RequestService } from '../data/request.service'; import { FindListOptions } from '../data/request.models'; +import { Collection } from '../shared/collection.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { ResourcePolicy } from '../shared/resource-policy.model'; import { RemoteData } from '../data/remote-data'; @@ -14,19 +17,22 @@ import { RemoteDataBuildService } from '../cache/builders/remote-data-build.serv import { CoreState } from '../core.reducers'; import { ObjectCacheService } from '../cache/object-cache.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; +import { RESOURCE_POLICY } from '../shared/resource-policy.resource-type'; import { ChangeAnalyzer } from './change-analyzer'; import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; -import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { PaginatedList } from './paginated-list'; /* tslint:disable:max-classes-per-file */ + +/** + * A private DataService implementation to delegate specific methods to. + */ class DataServiceImpl extends DataService { protected linkPath = 'resourcepolicies'; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, @@ -36,31 +42,54 @@ class DataServiceImpl extends DataService { super(); } - getBrowseEndpoint(options: FindListOptions = {}, linkPath: string = this.linkPath): Observable { - return this.halService.getEndpoint(linkPath); - } } /** * A service responsible for fetching/sending data from/to the REST API on the resourcepolicies endpoint */ @Injectable() +@dataService(RESOURCE_POLICY) export class ResourcePolicyService { private dataService: DataServiceImpl; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected dataBuildService: NormalizedObjectBuildService, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, protected notificationsService: NotificationsService, protected http: HttpClient, protected comparator: DefaultChangeAnalyzer) { - this.dataService = new DataServiceImpl(requestService, rdbService, dataBuildService, null, objectCache, halService, notificationsService, http, comparator); + this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); } - findByHref(href: string, options?: HttpOptions): Observable> { - return this.dataService.findByHref(href, options); + /** + * Returns an observable of {@link RemoteData} of a {@link ResourcePolicy}, based on an href, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the {@link ResourcePolicy} + * @param href The url of {@link ResourcePolicy} we want to retrieve + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + findByHref(href: string, ...linksToFollow: Array>): Observable> { + return this.dataService.findByHref(href, ...linksToFollow); + } + + /** + * Returns a list of observables of {@link RemoteData} of {@link ResourcePolicy}s, based on an href, with a list of {@link FollowLinkConfig}, + * to automatically resolve {@link HALLink}s of the {@link ResourcePolicy} + * @param href The url of the {@link ResourcePolicy} we want to retrieve + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + findAllByHref(href: string, findListOptions: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { + return this.dataService.findAllByHref(href, findListOptions, ...linksToFollow); + } + + /** + * Return the defaultAccessConditions {@link ResourcePolicy} list for a given {@link Collection} + * + * @param collection the {@link Collection} to retrieve the defaultAccessConditions for + * @param findListOptions the {@link FindListOptions} for the request + */ + getDefaultAccessConditionsFor(collection: Collection, findListOptions?: FindListOptions): Observable>> { + return this.dataService.findAllByHref(collection._links.defaultAccessConditions.href, findListOptions); } } diff --git a/src/app/core/data/search-response-parsing.service.ts b/src/app/core/data/search-response-parsing.service.ts index c449fa872f..ed47250922 100644 --- a/src/app/core/data/search-response-parsing.service.ts +++ b/src/app/core/data/search-response-parsing.service.ts @@ -1,13 +1,13 @@ import { Injectable } from '@angular/core'; +import { hasValue } from '../../shared/empty.util'; +import { SearchQueryResponse } from '../../shared/search/search-query-response.model'; import { RestResponse, SearchSuccessResponse } from '../cache/response.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; +import { MetadataMap, MetadataValue } from '../shared/metadata.models'; import { DSOResponseParsingService } from './dso-response-parsing.service'; import { ResponseParsingService } from './parsing.service'; import { RestRequest } from './request.models'; -import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; -import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; -import { hasValue } from '../../shared/empty.util'; -import { SearchQueryResponse } from '../../shared/search/search-query-response.model'; -import { MetadataMap, MetadataValue } from '../shared/metadata.models'; @Injectable() export class SearchResponseParsingService implements ResponseParsingService { @@ -42,10 +42,6 @@ export class SearchResponseParsingService implements ResponseParsingService { const dsoSelfLinks = payload._embedded.objects .filter((object) => hasValue(object._embedded)) .map((object) => object._embedded.indexableObject) - // we don't need embedded collections, bitstreamformats, etc for search results. - // And parsing them all takes up a lot of time. Throw them away to improve performance - // until objs until partial results are supported by the rest api - .map((dso) => Object.assign({}, dso, { _embedded: undefined })) .map((dso) => this.dsoParser.parse(request, { payload: dso, statusCode: data.statusCode, @@ -59,13 +55,9 @@ export class SearchResponseParsingService implements ResponseParsingService { .map((object, index) => Object.assign({}, object, { indexableObject: dsoSelfLinks[index], hitHighlights: hitHighlights[index], - // we don't need embedded collections, bitstreamformats, etc for search results. - // And parsing them all takes up a lot of time. Throw them away to improve performance - // until objs until partial results are supported by the rest api - _embedded: undefined })); payload.objects = objects; - const deserialized = new DSpaceRESTv2Serializer(SearchQueryResponse).deserialize(payload); + const deserialized = new DSpaceSerializer(SearchQueryResponse).deserialize(payload); return new SearchSuccessResponse(deserialized, data.statusCode, data.statusText, this.dsoParser.processPageInfo(payload)); } } diff --git a/src/app/core/data/site-data.service.spec.ts b/src/app/core/data/site-data.service.spec.ts index 6148135f50..6938cd65a9 100644 --- a/src/app/core/data/site-data.service.spec.ts +++ b/src/app/core/data/site-data.service.spec.ts @@ -8,7 +8,6 @@ import { Store } from '@ngrx/store'; import { CoreState } from '../core.reducers'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { HttpClient } from '@angular/common/http'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { of as observableOf } from 'rxjs'; import { RestResponse } from '../cache/response.models'; @@ -63,12 +62,10 @@ describe('SiteDataService', () => { const notificationsService = {} as NotificationsService; const http = {} as HttpClient; const comparator = {} as any; - const dataBuildService = {} as NormalizedObjectBuildService; service = new SiteDataService( requestService, rdbService, - dataBuildService, store, objectCache, halService, diff --git a/src/app/core/data/site-data.service.ts b/src/app/core/data/site-data.service.ts index c1a1b2069b..7b2bfdb543 100644 --- a/src/app/core/data/site-data.service.ts +++ b/src/app/core/data/site-data.service.ts @@ -1,35 +1,34 @@ -import { DataService } from './data.service'; -import { Site } from '../shared/site.model'; -import { RequestService } from './request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-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 { DSOChangeAnalyzer } from './dso-change-analyzer.service'; -import { FindListOptions } from './request.models'; +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { RemoteData } from './remote-data'; -import { PaginatedList } from './paginated-list'; -import { Injectable } from '@angular/core'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { dataService } from '../cache/builders/build-decorators'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { CoreState } from '../core.reducers'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; import { getSucceededRemoteData } from '../shared/operators'; +import { Site } from '../shared/site.model'; +import { SITE } from '../shared/site.resource-type'; +import { DataService } from './data.service'; +import { DSOChangeAnalyzer } from './dso-change-analyzer.service'; +import { PaginatedList } from './paginated-list'; +import { RemoteData } from './remote-data'; +import { RequestService } from './request.service'; /** * Service responsible for handling requests related to the Site object */ @Injectable() +@dataService(SITE) export class SiteDataService extends DataService {​ protected linkPath = 'sites'; - protected forceBypassCache = false; constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, @@ -40,15 +39,6 @@ export class SiteDataService extends DataService {​ super(); } - /** - * Get the endpoint for browsing the site object - * @param {FindListOptions} options - * @param {Observable} linkPath - */ - getBrowseEndpoint(options: FindListOptions, linkPath?: string): Observable { - return this.halService.getEndpoint(this.linkPath); - } - /** * Retrieve the Site Object */ 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/data/workflow-action-data.service.ts b/src/app/core/data/workflow-action-data.service.ts new file mode 100644 index 0000000000..be2a170ac5 --- /dev/null +++ b/src/app/core/data/workflow-action-data.service.ts @@ -0,0 +1,41 @@ +import { DataService } from './data.service'; +import { WorkflowAction } from '../tasks/models/workflow-action-object.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 { Injectable } from '@angular/core'; +import { dataService } from '../cache/builders/build-decorators'; +import { WORKFLOW_ACTION } from '../tasks/models/workflow-action-object.resource-type'; + +/** + * A service responsible for fetching/sending data from/to the REST API on the workflowactions endpoint + */ +@Injectable() +@dataService(WORKFLOW_ACTION) +export class WorkflowActionDataService extends DataService { + protected linkPath = 'workflowactions'; + + 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(); + } + + getBrowseEndpoint(options: FindListOptions, linkPath?: string): Observable { + return this.halService.getEndpoint(this.linkPath); + } +} diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2.serializer.spec.ts b/src/app/core/dspace-rest-v2/dspace-rest-v2.serializer.spec.ts deleted file mode 100644 index 8431d6f8b3..0000000000 --- a/src/app/core/dspace-rest-v2/dspace-rest-v2.serializer.spec.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { autoserialize, autoserializeAs } from 'cerialize'; - -import { DSpaceRESTv2Serializer } from './dspace-rest-v2.serializer'; - -class TestModel { - @autoserialize - id: string; - - @autoserialize - name: string; - - @autoserializeAs(TestModel) - parents?: TestModel[]; -} - -const testModels = [ - { - id: 'd4466d54-d73b-4d8f-b73f-c702020baa14', - name: 'Model 1', - }, - { - id: '752a1250-949a-46ad-9bea-fbc45f0b656d', - name: 'Model 2', - } -]; - -const testResponses = [ - { - _links: { - self: '/testmodels/9e32a2e2-6b91-4236-a361-995ccdc14c60', - parents: [ - { href: '/testmodels/21539b1d-9ef1-4eda-9c77-49565b5bfb78' }, - { href: '/testmodels/be8325f7-243b-49f4-8a4b-df2b793ff3b5' } - ] - }, - id: '9e32a2e2-6b91-4236-a361-995ccdc14c60', - type: 'testModels', - name: 'A Test Model' - }, - { - _links: { - self: '/testmodels/598ce822-c357-46f3-ab70-63724d02d6ad', - parents: [ - { href: '/testmodels/be8325f7-243b-49f4-8a4b-df2b793ff3b5' }, - { href: '/testmodels/21539b1d-9ef1-4eda-9c77-49565b5bfb78' } - ] - }, - id: '598ce822-c357-46f3-ab70-63724d02d6ad', - type: 'testModels', - name: 'Another Test Model' - } -]; - -const parentHrefRegex = /^\/testmodels\/(.+)$/g; - -describe('DSpaceRESTv2Serializer', () => { - - describe('serialize', () => { - - it('should turn a model in to a valid document', () => { - const serializer = new DSpaceRESTv2Serializer(TestModel); - const doc = serializer.serialize(testModels[0]); - expect(testModels[0].id).toBe(doc.id); - expect(testModels[0].name).toBe(doc.name); - }); - - }); - - describe('serializeArray', () => { - - it('should turn an array of models in to a valid document', () => { - const serializer = new DSpaceRESTv2Serializer(TestModel); - const doc = serializer.serializeArray(testModels); - - expect(testModels[0].id).toBe(doc[0].id); - expect(testModels[0].name).toBe(doc[0].name); - expect(testModels[1].id).toBe(doc[1].id); - expect(testModels[1].name).toBe(doc[1].name); - }); - - }); - - describe('deserialize', () => { - - it('should turn a valid document describing a single entity in to a valid model', () => { - const serializer = new DSpaceRESTv2Serializer(TestModel); - const model = serializer.deserialize(testResponses[0]); - - expect(model.id).toBe(testResponses[0].id); - expect(model.name).toBe(testResponses[0].name); - }); - - // TODO: cant implement/test this yet - depends on how relationships - // will be handled in the rest api - // it('should retain relationship information', () => { - // const serializer = new DSpaceRESTv2Serializer(TestModel); - // const doc = { - // '_embedded': testResponses[0], - // }; - // - // const model = serializer.deserialize(doc); - // - // console.log(model); - // - // const modelParentIds = model.parents.map(parent => parent.id).sort(); - // const responseParentIds = doc._embedded._links.parents - // .map(parent => parent.href) - // .map(href => href.replace(parentHrefRegex, '$1')) - // .sort(); - // - // expect(modelParentIds).toEqual(responseParentIds); - // }); - - // TODO enable once validation is enabled in the serializer - // it('should throw an error when dealing with an invalid document', () => { - // const serializer = new DSpaceRESTv2Serializer(TestModel); - // const doc = testResponses[0]; - // - // expect(() => { - // serializer.deserialize(doc); - // }).toThrow(); - // }); - - it('should throw an error when dealing with a document describing an array', () => { - const serializer = new DSpaceRESTv2Serializer(TestModel); - expect(() => { - serializer.deserialize(testResponses); - }).toThrow(); - }); - - }); - - describe('deserializeArray', () => { - - // TODO: rewrite to incorporate normalisation. - // it('should turn a valid document describing a collection of objects in to an array of valid models', () => { - // const serializer = new DSpaceRESTv2Serializer(TestModel); - // const doc = { - // '_embedded': testResponses - // }; - // - // const models = serializer.deserializeArray(doc); - // - // expect(models[0].id).toBe(doc._embedded[0].id); - // expect(models[0].name).toBe(doc._embedded[0].name); - // expect(models[1].id).toBe(doc._embedded[1].id); - // expect(models[1].name).toBe(doc._embedded[1].name); - // }); - - // TODO: cant implement/test this yet - depends on how relationships - // will be handled in the rest api - // it('should retain relationship information', () => { - // const serializer = new DSpaceRESTv2Serializer(TestModel); - // const doc = { - // '_embedded': testResponses, - // }; - // - // const models = serializer.deserializeArray(doc); - // - // models.forEach((model, i) => { - // const modelParentIds = model.parents.map(parent => parent.id).sort(); - // const responseParentIds = doc._embedded[i]._links.parents - // .map(parent => parent.href) - // .map(href => href.replace(parentHrefRegex, '$1')) - // .sort(); - // - // expect(modelParentIds).toEqual(responseParentIds); - // }); - // }); - - // TODO enable once validation is enabled in the serializer - // it('should throw an error when dealing with an invalid document', () => { - // const serializer = new DSpaceRESTv2Serializer(TestModel); - // const doc = testResponses[0]; - // - // expect(() => { - // serializer.deserializeArray(doc); - // }).toThrow(); - // }); - - it('should throw an error when dealing with a document describing a single model', () => { - const serializer = new DSpaceRESTv2Serializer(TestModel); - const doc = { - _embedded: testResponses[0] - }; - - expect(() => { - serializer.deserializeArray(doc); - }).toThrow(); - }); - - }); - -}); 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 cf9b1067c1..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 @@ -4,7 +4,6 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpHeaders, HttpParams, HttpResponse } from '@angular/common/http' import { DSpaceRESTV2Response } from './dspace-rest-v2-response.model'; -import { HttpObserve } from '@angular/common/http/src/client'; import { RestRequestMethod } from '../data/rest-request-method'; import { hasNoValue, isNotEmpty } from '../../shared/empty.util'; import { DSpaceObject } from '../shared/dspace-object.model'; @@ -14,7 +13,7 @@ export interface HttpOptions { body?: any; headers?: HttpHeaders; params?: HttpParams; - observe?: HttpObserve; + observe?: 'body' | 'events' | 'response'; reportProgress?: boolean; responseType?: 'arraybuffer' | 'blob' | 'json' | 'text'; withCredentials?: boolean; @@ -91,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/dspace-rest-v2/dspace.serializer.spec.ts b/src/app/core/dspace-rest-v2/dspace.serializer.spec.ts new file mode 100644 index 0000000000..b07a4f97d1 --- /dev/null +++ b/src/app/core/dspace-rest-v2/dspace.serializer.spec.ts @@ -0,0 +1,154 @@ +import { autoserialize, autoserializeAs, deserialize } from 'cerialize'; +import { HALLink } from '../shared/hal-link.model'; +import { HALResource } from '../shared/hal-resource.model'; +import { DSpaceSerializer } from './dspace.serializer'; + +class TestModel implements HALResource { + @autoserialize + id: string; + + @autoserialize + name: string; + + @deserialize + _links: { + self: HALLink; + parents: HALLink; + } +} + +const testModels = [ + { + id: 'd4466d54-d73b-4d8f-b73f-c702020baa14', + name: 'Model 1', + _links: { + self: { + href: '/testmodels/9e32a2e2-6b91-4236-a361-995ccdc14c60' + }, + parents: { + href: '/testmodels/9e32a2e2-6b91-4236-a361-995ccdc14c60/parents' + } + } + }, + { + id: '752a1250-949a-46ad-9bea-fbc45f0b656d', + name: 'Model 2', + _links: { + self: { + href: '/testmodels/598ce822-c357-46f3-ab70-63724d02d6ad' + }, + parents: { + href: '/testmodels/598ce822-c357-46f3-ab70-63724d02d6ad/parents' + } + } + } +]; + +const testResponses = [ + { + _links: { + self: { + href: '/testmodels/9e32a2e2-6b91-4236-a361-995ccdc14c60' + }, + parents: { + href: '/testmodels/9e32a2e2-6b91-4236-a361-995ccdc14c60/parents' + } + }, + id: '9e32a2e2-6b91-4236-a361-995ccdc14c60', + type: 'testModels', + name: 'A Test Model' + }, + { + _links: { + self: { + href: '/testmodels/598ce822-c357-46f3-ab70-63724d02d6ad' + }, + parents: { + href: '/testmodels/598ce822-c357-46f3-ab70-63724d02d6ad/parents' + } + }, + id: '598ce822-c357-46f3-ab70-63724d02d6ad', + type: 'testModels', + name: 'Another Test Model' + } +]; + +describe('DSpaceSerializer', () => { + + describe('serialize', () => { + + it('should turn a model in to a valid document', () => { + const serializer = new DSpaceSerializer(TestModel); + const doc = serializer.serialize(testModels[0]); + expect(doc.id).toBe(testModels[0].id); + expect(doc.name).toBe(testModels[0].name); + expect(doc._links).toBeUndefined(); + }); + + }); + + describe('serializeArray', () => { + + it('should turn an array of models in to a valid document', () => { + const serializer = new DSpaceSerializer(TestModel); + const doc = serializer.serializeArray(testModels); + + expect(doc[0].id).toBe(testModels[0].id); + expect(doc[0].name).toBe(testModels[0].name); + expect(doc[0]._links).toBeUndefined(); + expect(doc[1].id).toBe(testModels[1].id); + expect(doc[1].name).toBe(testModels[1].name); + expect(doc[1]._links).toBeUndefined(); + }); + + }); + + describe('deserialize', () => { + + it('should turn a valid document describing a single entity in to a valid model', () => { + const serializer = new DSpaceSerializer(TestModel); + const model = serializer.deserialize(testResponses[0]); + + expect(model.id).toBe(testResponses[0].id); + expect(model.name).toBe(testResponses[0].name); + }); + + it('should throw an error when dealing with a document describing an array', () => { + const serializer = new DSpaceSerializer(TestModel); + expect(() => { + serializer.deserialize(testResponses); + }).toThrow(); + }); + + }); + + describe('deserializeArray', () => { + + it('should throw an error when dealing with a document describing a single model', () => { + const serializer = new DSpaceSerializer(TestModel); + const doc = { + _embedded: testResponses[0] + }; + + expect(() => { + serializer.deserializeArray(doc); + }).toThrow(); + }); + + it('should turn an array of responses in to valid models', () => { + const serializer = new DSpaceSerializer(TestModel); + const output = serializer.deserializeArray(testResponses); + + expect(testResponses[0].id).toBe(output[0].id); + expect(testResponses[0].name).toBe(output[0].name); + expect(testResponses[0]._links.self.href).toBe(output[0]._links.self.href); + expect(testResponses[0]._links.parents.href).toBe(output[0]._links.parents.href); + expect(testResponses[1].id).toBe(output[1].id); + expect(testResponses[1].name).toBe(output[1].name); + expect(testResponses[1]._links.self.href).toBe(output[1]._links.self.href); + expect(testResponses[1]._links.parents.href).toBe(output[1]._links.parents.href); + }); + + }); + +}); diff --git a/src/app/core/dspace-rest-v2/dspace-rest-v2.serializer.ts b/src/app/core/dspace-rest-v2/dspace.serializer.ts similarity index 53% rename from src/app/core/dspace-rest-v2/dspace-rest-v2.serializer.ts rename to src/app/core/dspace-rest-v2/dspace.serializer.ts index 258edb116d..e16094a040 100644 --- a/src/app/core/dspace-rest-v2/dspace-rest-v2.serializer.ts +++ b/src/app/core/dspace-rest-v2/dspace.serializer.ts @@ -1,19 +1,16 @@ -import { Serialize, Deserialize } from 'cerialize'; +import { Deserialize, Serialize } from 'cerialize'; import { Serializer } from '../serializer'; -import { DSpaceRESTV2Response } from './dspace-rest-v2-response.model'; -import { DSpaceRESTv2Validator } from './dspace-rest-v2.validator'; import { GenericConstructor } from '../shared/generic-constructor'; -import { hasNoValue, hasValue } from '../../shared/empty.util'; /** * This Serializer turns responses from v2 of DSpace's REST API * to models and vice versa */ -export class DSpaceRESTv2Serializer implements Serializer { +export class DSpaceSerializer implements Serializer { /** - * Create a new DSpaceRESTv2Serializer instance + * Create a new DSpaceSerializer instance * * @param modelType a class or interface to indicate * the kind of model this serializer should work with @@ -48,13 +45,10 @@ export class DSpaceRESTv2Serializer implements Serializer { * @returns a model of type T */ deserialize(response: any): T { - // TODO enable validation, once rest data stabilizes - // new DSpaceRESTv2Validator(response).validate(); if (Array.isArray(response)) { throw new Error('Expected a single model, use deserializeArray() instead'); } - const normalized = Object.assign({}, response, this.normalizeLinks(response._links)); - return Deserialize(normalized, this.modelType) as T; + return Deserialize(response, this.modelType) as T; } /** @@ -64,30 +58,9 @@ export class DSpaceRESTv2Serializer implements Serializer { * @returns an array of models of type T */ deserializeArray(response: any): T[] { - // TODO: enable validation, once rest data stabilizes - // new DSpaceRESTv2Validator(response).validate(); if (!Array.isArray(response)) { throw new Error('Expected an Array, use deserialize() instead'); } - const normalized = response.map((resource) => { - return Object.assign({}, resource, this.normalizeLinks(resource._links)); - }); - - return Deserialize(normalized, this.modelType) as T[]; + return Deserialize(response, this.modelType) as T[]; } - - private normalizeLinks(links: any): any { - const normalizedLinks = links; - for (const link in normalizedLinks) { - if (Array.isArray(normalizedLinks[link])) { - normalizedLinks[link] = normalizedLinks[link].map((linkedResource) => { - return linkedResource.href; - }); - } else { - normalizedLinks[link] = normalizedLinks[link].href; - } - } - return normalizedLinks; - } - } 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..cd7bc72884 --- /dev/null +++ b/src/app/core/eperson/eperson-data.service.spec.ts @@ -0,0 +1,307 @@ +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 { 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; + + let epeople; + + let restEndpointURL; + let epersonsEndpoint; + let halService: any; + let epeople$; + let rdbService; + + let getRequestEntry$; + + function initTestService() { + return new EPersonDataService( + requestService, + rdbService, + store, + null, + halService, + null, + null, + new DummyChangeAnalyzer() as any + ); + } + + function init() { + getRequestEntry$ = (successful: boolean) => { + return observableOf({ + completed: true, + response: { isSuccessful: successful, payload: epeople } as any + } as RequestEntry) + }; + restEndpointURL = 'https://dspace.4science.it/dspace-spring-rest/api/eperson'; + epersonsEndpoint = `${restEndpointURL}/epersons`; + epeople = [EPersonMock, EPersonMock2]; + epeople$ = createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [epeople])); + rdbService = getMockRemoteDataBuildServiceHrefMap(undefined, { 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons': epeople$ }); + halService = new HALEndpointServiceStub(restEndpointURL); + + TestBed.configureTestingModule({ + imports: [ + CommonModule, + StoreModule.forRoot({}), + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }), + ], + declarations: [], + providers: [], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }); + } + + beforeEach(() => { + init(); + 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 new file mode 100644 index 0000000000..a8cee6f1de --- /dev/null +++ b/src/app/core/eperson/eperson-data.service.ts @@ -0,0 +1,252 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +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 { 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 & EPerson related CRUD actions + */ +@Injectable() +@dataService(EPERSON) +export class EPersonDataService extends DataService { + + protected linkPath = 'epersons'; + protected searchByEmailPath = 'byEmail'; + protected searchByMetadataPath = 'byMetadata'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected notificationsService: NotificationsService, + protected http: HttpClient, + protected comparator: DSOChangeAnalyzer + ) { + 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 + */ + public clearEPersonRequests(): void { + this.getBrowseEndpoint().pipe(take(1)).subscribe((link: string) => { + this.requestService.removeByHrefSubstring(link); + }); + } + + /** + * Method that clears a link's requests in cache + */ + public clearLinkRequests(href: string): void { + this.requestService.removeByHrefSubstring(href); + } + + /** + * 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); + } + + /** + * Change which ePerson is being edited and return the link for EPeople edit page + * @param ePerson New EPerson to edit + */ + public startEditingNewEPerson(ePerson: EPerson): string { + this.getActiveEPerson().pipe(take(1)).subscribe((activeEPerson: EPerson) => { + if (ePerson === activeEPerson) { + this.cancelEditEPerson(); + } else { + this.editEPerson(ePerson); + } + }); + return '/admin/access-control/epeople'; + } + + /** + * Get EPeople admin page + * @param ePerson New EPerson to edit + */ + public getEPeoplePageRouterLink(): string { + return '/admin/access-control/epeople'; + } + +} diff --git a/src/app/core/eperson/eperson.service.ts b/src/app/core/eperson/eperson.service.ts deleted file mode 100644 index 81ae532e3b..0000000000 --- a/src/app/core/eperson/eperson.service.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Observable } from 'rxjs'; -import { FindListOptions } from '../data/request.models'; -import { DataService } from '../data/data.service'; -import { CacheableObject } from '../cache/object-cache.reducer'; - -/** - * An abstract class that provides methods to make HTTP request to eperson endpoint. - */ -export abstract class EpersonService extends DataService { - - public getBrowseEndpoint(options: FindListOptions): Observable { - return this.halService.getEndpoint(this.linkPath); - } -} diff --git a/src/app/core/eperson/group-data.service.spec.ts b/src/app/core/eperson/group-data.service.spec.ts new file mode 100644 index 0000000000..138cf547f2 --- /dev/null +++ b/src/app/core/eperson/group-data.service.spec.ts @@ -0,0 +1,198 @@ +import { CommonModule } from '@angular/common'; +import { HttpHeaders } from '@angular/common/http'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Store, StoreModule } from '@ngrx/store'; +import { of as observableOf } from 'rxjs'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { compare, Operation } from 'fast-json-patch'; +import { + GroupRegistryCancelGroupAction, + GroupRegistryEditGroupAction +} from '../../+admin/admin-access-control/group-registry/group-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 { GroupMock, GroupMock2 } from '../../shared/testing/group-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 { CoreState } from '../core.reducers'; +import { ChangeAnalyzer } from '../data/change-analyzer'; +import { PaginatedList } from '../data/paginated-list'; +import { DeleteByIDRequest, DeleteRequest, FindListOptions, PostRequest } from '../data/request.models'; +import { RequestEntry } from '../data/request.reducer'; +import { RequestService } from '../data/request.service'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { Item } from '../shared/item.model'; +import { PageInfo } from '../shared/page-info.model'; +import { GroupDataService } from './group-data.service'; + +describe('GroupDataService', () => { + let service: GroupDataService; + let store: Store; + let requestService: RequestService; + + let restEndpointURL; + let groupsEndpoint; + let groups; + let groups$; + let halService; + let rdbService; + + let getRequestEntry$; + + function init() { + getRequestEntry$ = (successful: boolean) => { + return observableOf({ + completed: true, + response: { isSuccessful: successful, payload: groups } as any + } as RequestEntry) + }; + restEndpointURL = 'https://dspace.4science.it/dspace-spring-rest/api/eperson'; + groupsEndpoint = `${restEndpointURL}/groups`; + groups = [GroupMock, GroupMock2]; + groups$ = createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), groups)); + rdbService = getMockRemoteDataBuildServiceHrefMap(undefined, { 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups': groups$ }); + halService = new HALEndpointServiceStub(restEndpointURL); + TestBed.configureTestingModule({ + imports: [ + CommonModule, + StoreModule.forRoot({}), + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }), + ], + declarations: [], + providers: [], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }); + } + + function initTestService() { + return new GroupDataService( + new DummyChangeAnalyzer() as any, + null, + null, + requestService, + rdbService, + store, + null, + halService, + null, + ); + }; + + beforeEach(() => { + init(); + requestService = getMockRequestService(getRequestEntry$(true)); + store = new Store(undefined, undefined, undefined); + service = initTestService(); + spyOn(store, 'dispatch'); + }); + + describe('searchGroups', () => { + beforeEach(() => { + spyOn(service, 'searchBy'); + }); + + it('search with empty query', () => { + service.searchGroups(''); + const options = Object.assign(new FindListOptions(), { + searchParams: [Object.assign(new SearchParam('query', ''))] + }); + expect(service.searchBy).toHaveBeenCalledWith('byMetadata', options); + }); + + it('search with query', () => { + service.searchGroups('test'); + const options = Object.assign(new FindListOptions(), { + searchParams: [Object.assign(new SearchParam('query', 'test'))] + }); + expect(service.searchBy).toHaveBeenCalledWith('byMetadata', options); + }); + }); + + describe('deleteGroup', () => { + beforeEach(() => { + service.deleteGroup(GroupMock2).subscribe(); + }); + + it('should send DeleteRequest', () => { + const expected = new DeleteByIDRequest(requestService.generateRequestId(), groupsEndpoint + '/' + GroupMock2.uuid, GroupMock2.uuid); + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + }); + + describe('addSubGroupToGroup', () => { + beforeEach(() => { + service.addSubGroupToGroup(GroupMock, GroupMock2).subscribe(); + }); + it('should send PostRequest to eperson/groups/group-id/subgroups endpoint with new subgroup link in body', () => { + let headers = new HttpHeaders(); + const options: HttpOptions = Object.create({}); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + const expected = new PostRequest(requestService.generateRequestId(), GroupMock.self + '/' + service.subgroupsEndpoint, GroupMock2.self, options); + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + }); + + describe('deleteSubGroupFromGroup', () => { + beforeEach(() => { + service.deleteSubGroupFromGroup(GroupMock, GroupMock2).subscribe(); + }); + it('should send DeleteRequest to eperson/groups/group-id/subgroups/group-id endpoint', () => { + const expected = new DeleteRequest(requestService.generateRequestId(), GroupMock.self + '/' + service.subgroupsEndpoint + '/' + GroupMock2.id); + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + }); + + describe('addMemberToGroup', () => { + beforeEach(() => { + service.addMemberToGroup(GroupMock, EPersonMock2).subscribe(); + }); + it('should send PostRequest to eperson/groups/group-id/epersons endpoint with new eperson member in body', () => { + let headers = new HttpHeaders(); + const options: HttpOptions = Object.create({}); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + const expected = new PostRequest(requestService.generateRequestId(), GroupMock.self + '/' + service.ePersonsEndpoint, EPersonMock2.self, options); + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + }); + + describe('deleteMemberFromGroup', () => { + beforeEach(() => { + service.deleteMemberFromGroup(GroupMock, EPersonMock).subscribe(); + }); + it('should send DeleteRequest to eperson/groups/group-id/epersons/eperson-id endpoint', () => { + const expected = new DeleteRequest(requestService.generateRequestId(), GroupMock.self + '/' + service.ePersonsEndpoint + '/' + EPersonMock.id); + expect(requestService.configure).toHaveBeenCalledWith(expected); + }); + }); + + describe('editGroup', () => { + it('should dispatch a EDIT_GROUP action with the groupp to start editing', () => { + service.editGroup(GroupMock); + expect(store.dispatch).toHaveBeenCalledWith(new GroupRegistryEditGroupAction(GroupMock)); + }); + }); + + describe('cancelEditGroup', () => { + it('should dispatch a CANCEL_EDIT_GROUP action', () => { + service.cancelEditGroup(); + expect(store.dispatch).toHaveBeenCalledWith(new GroupRegistryCancelGroupAction()); + }); + }); +}); + +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/group-data.service.ts b/src/app/core/eperson/group-data.service.ts new file mode 100644 index 0000000000..574b4d997a --- /dev/null +++ b/src/app/core/eperson/group-data.service.ts @@ -0,0 +1,375 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { createSelector, select, Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { distinctUntilChanged, filter, map, switchMap, take, tap } from 'rxjs/operators'; +import { + GroupRegistryCancelGroupAction, + GroupRegistryEditGroupAction +} from '../../+admin/admin-access-control/group-registry/group-registry.actions'; +import { GroupRegistryState } from '../../+admin/admin-access-control/group-registry/group-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 { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { SearchParam } from '../cache/models/search-param.model'; +import { ObjectCacheService } from '../cache/object-cache.service'; +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 { + CreateRequest, + DeleteRequest, + FindListOptions, + FindListRequest, + PostRequest +} from '../data/request.models'; + +import { RequestService } from '../data/request.service'; +import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { configureRequest, getResponseFromEntry} from '../shared/operators'; +import { EPerson } from './models/eperson.model'; +import { Group } from './models/group.model'; +import { dataService } from '../cache/builders/build-decorators'; +import { GROUP } from './models/group.resource-type'; +import { DSONameService } from '../breadcrumbs/dso-name.service'; +import { Community } from '../shared/community.model'; +import { Collection } from '../shared/collection.model'; +import { ComcolRole } from '../../shared/comcol-forms/edit-comcol-page/comcol-role/comcol-role'; + +const groupRegistryStateSelector = (state: AppState) => state.groupRegistry; +const editGroupSelector = createSelector(groupRegistryStateSelector, (groupRegistryState: GroupRegistryState) => groupRegistryState.editGroup); + +/** + * Provides methods to retrieve eperson group resources from the REST API & Group related CRUD actions. + */ +@Injectable({ + providedIn: 'root' +}) +@dataService(GROUP) +export class GroupDataService extends DataService { + protected linkPath = 'groups'; + protected browseEndpoint = ''; + public ePersonsEndpoint = 'epersons'; + public subgroupsEndpoint = 'subgroups'; + + constructor( + protected comparator: DSOChangeAnalyzer, + protected http: HttpClient, + protected notificationsService: NotificationsService, + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected objectCache: ObjectCacheService, + protected halService: HALEndpointService, + protected nameService: DSONameService, + ) { + super(); + } + + /** + * Retrieves all groups + * @param pagination The pagination info used to retrieve the groups + */ + public getGroups(options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { + const hrefObs = this.getFindAllHref(options, this.linkPath, ...linksToFollow); + 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>>; + } + + /** + * Returns a search result list of groups, with certain query (searches in group name and by exact uuid) + * Endpoint used: /eperson/groups/search/byMetadata?query=<:name> + * @param query search query param + * @param options + * @param linksToFollow + */ + public searchGroups(query: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { + const searchParams = [new SearchParam('query', query)]; + 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('byMetadata', findListOptions, ...linksToFollow); + } + + /** + * Check if the current user is member of to the indicated group + * + * @param groupName + * the group name + * @return boolean + * true if user is member of the indicated group, false otherwise + */ + isMemberOf(groupName: string): Observable { + const searchHref = 'isMemberOf'; + const options = new FindListOptions(); + options.searchParams = [new SearchParam('groupName', groupName)]; + + return this.searchBy(searchHref, options).pipe( + filter((groups: RemoteData>) => !groups.isResponsePending), + take(1), + map((groups: RemoteData>) => groups.payload.totalElements > 0) + ); + } + + /** + * Method to delete a group + * @param id The group id to delete + */ + public deleteGroup(group: Group): Observable { + return this.delete(group.id); + } + + /** + * Create or Update a group + * If the group contains an id, it is assumed the eperson already exists and is updated instead + * @param group The group to create or update + */ + public createOrUpdateGroup(group: Group): Observable> { + const isUpdate = hasValue(group.id); + if (isUpdate) { + return this.updateGroup(group); + } else { + return this.create(group, null); + } + } + + /** + * // TODO + * @param {DSpaceObject} ePerson The given object + */ + updateGroup(group: Group): Observable> { + // TODO + return null; + } + + /** + * Adds given subgroup as a subgroup to the given active group + * @param activeGroup Group we want to add subgroup to + * @param subgroup Group we want to add as subgroup to activeGroup + */ + addSubGroupToGroup(activeGroup: Group, subgroup: Group): Observable { + const requestId = this.requestService.generateRequestId(); + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + const postRequest = new PostRequest(requestId, activeGroup.self + '/' + this.subgroupsEndpoint, subgroup.self, options); + this.requestService.configure(postRequest); + + return this.fetchResponse(requestId); + } + + /** + * Deletes a given subgroup from the subgroups of the given active group + * @param activeGroup Group we want to delete subgroup from + * @param subgroup Subgroup we want to delete from activeGroup + */ + deleteSubGroupFromGroup(activeGroup: Group, subgroup: Group): Observable { + const requestId = this.requestService.generateRequestId(); + const deleteRequest = new DeleteRequest(requestId, activeGroup.self + '/' + this.subgroupsEndpoint + '/' + subgroup.id); + this.requestService.configure(deleteRequest); + + return this.fetchResponse(requestId); + } + + /** + * Adds given ePerson as member to given group + * @param activeGroup Group we want to add member to + * @param ePerson EPerson we want to add as member to given activeGroup + */ + addMemberToGroup(activeGroup: Group, ePerson: EPerson): Observable { + const requestId = this.requestService.generateRequestId(); + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + const postRequest = new PostRequest(requestId, activeGroup.self + '/' + this.ePersonsEndpoint, ePerson.self, options); + this.requestService.configure(postRequest); + + return this.fetchResponse(requestId); + } + + /** + * Deletes a given ePerson from the members of the given active group + * @param activeGroup Group we want to delete member from + * @param ePerson EPerson we want to delete from members of given activeGroup + */ + deleteMemberFromGroup(activeGroup: Group, ePerson: EPerson): Observable { + const requestId = this.requestService.generateRequestId(); + const deleteRequest = new DeleteRequest(requestId, activeGroup.self + '/' + this.ePersonsEndpoint + '/' + ePerson.id); + this.requestService.configure(deleteRequest); + + 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; + }) + ); + } + + /** + * Method to retrieve the group that is currently being edited + */ + public getActiveGroup(): Observable { + return this.store.pipe(select(editGroupSelector)) + } + + /** + * Method to cancel editing a group, dispatches a cancel group action + */ + public cancelEditGroup() { + this.store.dispatch(new GroupRegistryCancelGroupAction()); + } + + /** + * Method to set the group being edited, dispatches an edit group action + * @param group The group to edit + */ + public editGroup(group: Group) { + this.store.dispatch(new GroupRegistryEditGroupAction(group)); + } + + /** + * Method that clears a cached groups request + */ + public clearGroupsRequests(): void { + this.getBrowseEndpoint().pipe(take(1)).subscribe((link: string) => { + this.requestService.removeByHrefSubstring(link); + }); + } + + /** + * Method that clears a cached get subgroups of certain group request + */ + public clearGroupLinkRequests(href: string): void { + this.requestService.removeByHrefSubstring(href); + } + + public getGroupRegistryRouterLink(): string { + return '/admin/access-control/groups'; + } + + /** + * Change which group is being edited and return the link for the edit page of the new group being edited + * @param newGroup New group to edit + */ + public startEditingNewGroup(newGroup: Group): string { + this.getActiveGroup().pipe(take(1)).subscribe((activeGroup: Group) => { + if (newGroup === activeGroup) { + this.cancelEditGroup() + } else { + this.editGroup(newGroup) + } + }); + return this.getGroupEditPageRouterLinkWithID(newGroup.id) + } + + /** + * Get Edit page of group + * @param group Group we want edit page for + */ + public getGroupEditPageRouterLink(group: Group): string { + return this.getGroupEditPageRouterLinkWithID(group.id); + } + + /** + * Get Edit page of group + * @param groupID Group ID we want edit page for + */ + public getGroupEditPageRouterLinkWithID(groupId: string): string { + return '/admin/access-control/groups/' + groupId; + } + + /** + * Extract optional UUID from a string + * @param stringWithUUID String with possible UUID + */ + public getUUIDFromString(stringWithUUID: string): string { + let foundUUID = ''; + const uuidMatches = stringWithUUID.match(/[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}/g); + if (uuidMatches != null) { + foundUUID = uuidMatches[0]; + } + return foundUUID; + } + + /** + * Create a group for a given role for a given community or collection. + * + * @param dso The community or collection for which to create a group + * @param link The REST endpoint to create the group + */ + createComcolGroup(dso: Community|Collection, link: string): Observable { + + const requestId = this.requestService.generateRequestId(); + const group = Object.assign(new Group(), { + metadata: { + 'dc.description': [ + { + value: `${this.nameService.getName(dso)} admin group`, + } + ], + }, + }); + + this.requestService.configure( + new CreateRequest( + requestId, + link, + JSON.stringify(group), + )); + + return this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry(), + tap(() => this.requestService.removeByHrefSubstring(link)), + ); + } + + /** + * Delete the group for a given role for a given community or collection. + * + * @param link The REST endpoint to delete the group + */ + deleteComcolGroup(link: string): Observable { + + const requestId = this.requestService.generateRequestId(); + + this.requestService.configure( + new DeleteRequest( + requestId, + link, + )); + + return this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry(), + tap(() => this.requestService.removeByHrefSubstring(link)), + ); + } +} diff --git a/src/app/core/eperson/group-eperson.service.ts b/src/app/core/eperson/group-eperson.service.ts deleted file mode 100644 index c8a2a78917..0000000000 --- a/src/app/core/eperson/group-eperson.service.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; - -import { Store } from '@ngrx/store'; -import { Observable } from 'rxjs'; -import { filter, map, take } from 'rxjs/operators'; - -import { EpersonService } from './eperson.service'; -import { RequestService } from '../data/request.service'; -import { FindListOptions } from '../data/request.models'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { Group } from './models/group.model'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { CoreState } from '../core.reducers'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { SearchParam } from '../cache/models/search-param.model'; -import { RemoteData } from '../data/remote-data'; -import { PaginatedList } from '../data/paginated-list'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; -import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; - -/** - * Provides methods to retrieve eperson group resources. - */ -@Injectable() -export class GroupEpersonService extends EpersonService { - protected linkPath = 'groups'; - protected browseEndpoint = ''; - - constructor( - protected comparator: DSOChangeAnalyzer, - protected dataBuildService: NormalizedObjectBuildService, - protected http: HttpClient, - protected notificationsService: NotificationsService, - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService - ) { - super(); - } - - /** - * Check if the current user is member of to the indicated group - * - * @param groupName - * the group name - * @return boolean - * true if user is member of the indicated group, false otherwise - */ - isMemberOf(groupName: string): Observable { - const searchHref = 'isMemberOf'; - const options = new FindListOptions(); - options.searchParams = [new SearchParam('groupName', groupName)]; - - return this.searchBy(searchHref, options).pipe( - filter((groups: RemoteData>) => !groups.isResponsePending), - take(1), - map((groups: RemoteData>) => groups.payload.totalElements > 0) - ); - } - -} diff --git a/src/app/core/eperson/models/eperson.model.ts b/src/app/core/eperson/models/eperson.model.ts index d99a059e8b..bb99022112 100644 --- a/src/app/core/eperson/models/eperson.model.ts +++ b/src/app/core/eperson/models/eperson.model.ts @@ -1,52 +1,60 @@ +import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; import { Observable } from 'rxjs'; +import { link, typedObject } from '../../cache/builders/build-decorators'; +import { PaginatedList } from '../../data/paginated-list'; +import { RemoteData } from '../../data/remote-data'; import { DSpaceObject } from '../../shared/dspace-object.model'; +import { HALLink } from '../../shared/hal-link.model'; +import { EPERSON } from './eperson.resource-type'; import { Group } from './group.model'; -import { RemoteData } from '../../data/remote-data'; -import { PaginatedList } from '../../data/paginated-list'; -import { ResourceType } from '../../shared/resource-type'; +import { GROUP } from './group.resource-type'; +@typedObject +@inheritSerialization(DSpaceObject) export class EPerson extends DSpaceObject { - static type = new ResourceType('eperson'); + static type = EPERSON; /** * A string representing the unique handle of this Collection */ + @autoserialize public handle: string; - /** - * List of Groups that this EPerson belong to - */ - public groups: Observable>>; - /** * A string representing the netid of this EPerson */ + @autoserialize public netid: string; /** * A string representing the last active date for this EPerson */ + @autoserialize public lastActive: string; /** * A boolean representing if this EPerson can log in */ + @autoserialize public canLogIn: boolean; /** * The EPerson email address */ + @autoserialize public email: string; /** * A boolean representing if this EPerson require certificate */ + @autoserialize public requireCertificate: boolean; /** * A boolean representing if this EPerson registered itself */ + @autoserialize public selfRegistered: boolean; /** @@ -55,4 +63,17 @@ export class EPerson extends DSpaceObject { get name(): string { return this.firstMetadataValue('eperson.firstname') + ' ' + this.firstMetadataValue('eperson.lastname'); } + + _links: { + self: HALLink; + groups: HALLink; + }; + + /** + * The list of Groups this EPerson is part of + * Will be undefined unless the groups {@link HALLink} has been resolved. + */ + @link(GROUP, true) + public groups?: Observable>>; + } diff --git a/src/app/core/eperson/models/eperson.resource-type.ts b/src/app/core/eperson/models/eperson.resource-type.ts new file mode 100644 index 0000000000..8c91b3bca6 --- /dev/null +++ b/src/app/core/eperson/models/eperson.resource-type.ts @@ -0,0 +1,10 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for EPerson + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ + +export const EPERSON = new ResourceType('eperson'); diff --git a/src/app/core/eperson/models/group.model.ts b/src/app/core/eperson/models/group.model.ts index 9c14c20de7..e496babddc 100644 --- a/src/app/core/eperson/models/group.model.ts +++ b/src/app/core/eperson/models/group.model.ts @@ -1,30 +1,60 @@ +import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; import { Observable } from 'rxjs'; - -import { DSpaceObject } from '../../shared/dspace-object.model'; +import { link, typedObject } from '../../cache/builders/build-decorators'; import { PaginatedList } from '../../data/paginated-list'; import { RemoteData } from '../../data/remote-data'; -import { ResourceType } from '../../shared/resource-type'; +import { DSpaceObject } from '../../shared/dspace-object.model'; +import { HALLink } from '../../shared/hal-link.model'; +import { EPerson } from './eperson.model'; +import { EPERSON } from './eperson.resource-type'; +import { GROUP } from './group.resource-type'; + +@typedObject +@inheritSerialization(DSpaceObject) export class Group extends DSpaceObject { - static type = new ResourceType('group'); + static type = GROUP; /** - * List of Groups that this Group belong to + * A string representing the unique name of this Group */ - public groups: Observable>>; + @autoserialize + public name: string; /** * A string representing the unique handle of this Group */ + @autoserialize public handle: string; /** - * A string representing the name of this Group + * A boolean denoting whether this Group is permanent */ - public name: string; + @autoserialize + public permanent: boolean; /** - * A string representing the name of this Group is permanent + * The {@link HALLink}s for this Group */ - public permanent: boolean; + @deserialize + _links: { + self: HALLink; + subgroups: HALLink; + epersons: HALLink; + }; + + /** + * The list of Groups this Group is part of + * Will be undefined unless the groups {@link HALLink} has been resolved. + */ + @link(GROUP, true) + public subgroups?: Observable>>; + + /** + * The list of EPeople in this group + * Will be undefined unless the epersons {@link HALLink} has been resolved. + */ + @link(EPERSON, true) + public epersons?: Observable>>; + } diff --git a/src/app/core/eperson/models/group.resource-type.ts b/src/app/core/eperson/models/group.resource-type.ts new file mode 100644 index 0000000000..ad4a8bbccb --- /dev/null +++ b/src/app/core/eperson/models/group.resource-type.ts @@ -0,0 +1,10 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for Group + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ + +export const GROUP = new ResourceType('group'); diff --git a/src/app/core/eperson/models/normalized-eperson.model.ts b/src/app/core/eperson/models/normalized-eperson.model.ts deleted file mode 100644 index 489bf259c6..0000000000 --- a/src/app/core/eperson/models/normalized-eperson.model.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; - -import { CacheableObject } from '../../cache/object-cache.reducer'; -import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; -import { EPerson } from './eperson.model'; -import { mapsTo, relationship } from '../../cache/builders/build-decorators'; -import { Group } from './group.model'; - -@mapsTo(EPerson) -@inheritSerialization(NormalizedDSpaceObject) -export class NormalizedEPerson extends NormalizedDSpaceObject implements CacheableObject { - - /** - * A string representing the unique handle of this EPerson - */ - @autoserialize - public handle: string; - - /** - * List of Groups that this EPerson belong to - */ - @deserialize - @relationship(Group, true) - groups: string[]; - - /** - * A string representing the netid of this EPerson - */ - @autoserialize - public netid: string; - - /** - * A string representing the last active date for this EPerson - */ - @autoserialize - public lastActive: string; - - /** - * A boolean representing if this EPerson can log in - */ - @autoserialize - public canLogIn: boolean; - - /** - * The EPerson email address - */ - @autoserialize - public email: string; - - /** - * A boolean representing if this EPerson require certificate - */ - @autoserialize - public requireCertificate: boolean; - - /** - * A boolean representing if this EPerson registered itself - */ - @autoserialize - public selfRegistered: boolean; -} diff --git a/src/app/core/eperson/models/normalized-group.model.ts b/src/app/core/eperson/models/normalized-group.model.ts deleted file mode 100644 index 72b4e7b1a4..0000000000 --- a/src/app/core/eperson/models/normalized-group.model.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; - -import { CacheableObject } from '../../cache/object-cache.reducer'; -import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; -import { mapsTo, relationship } from '../../cache/builders/build-decorators'; -import { Group } from './group.model'; - -@mapsTo(Group) -@inheritSerialization(NormalizedDSpaceObject) -export class NormalizedGroup extends NormalizedDSpaceObject implements CacheableObject { - - /** - * List of Groups that this Group belong to - */ - @deserialize - @relationship(Group, true) - groups: string[]; - - /** - * A string representing the unique handle of this Group - */ - @autoserialize - public handle: string; - - /** - * A string representing the name of this Group - */ - @autoserialize - public name: string; - - /** - * A string representing the name of this Group is permanent - */ - @autoserialize - public permanent: boolean; -} diff --git a/src/app/core/eperson/models/workflowitem.resource-type.ts b/src/app/core/eperson/models/workflowitem.resource-type.ts new file mode 100644 index 0000000000..001b6b3f33 --- /dev/null +++ b/src/app/core/eperson/models/workflowitem.resource-type.ts @@ -0,0 +1,3 @@ +import { ResourceType } from '../../shared/resource-type'; + +export const WORKFLOWITEM = new ResourceType('workflowitem'); diff --git a/src/app/core/index/index.actions.ts b/src/app/core/index/index.actions.ts index 42804dbe26..d31f6ee2bd 100644 --- a/src/app/core/index/index.actions.ts +++ b/src/app/core/index/index.actions.ts @@ -91,4 +91,4 @@ export class RemoveFromIndexBySubstringAction implements Action { /** * A type to encompass all HrefIndexActions */ -export type IndexAction = AddToIndexAction | RemoveFromIndexByValueAction; +export type IndexAction = AddToIndexAction | RemoveFromIndexByValueAction | RemoveFromIndexBySubstringAction; diff --git a/src/app/core/index/index.effects.ts b/src/app/core/index/index.effects.ts index 61cf313ab1..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 { @@ -24,7 +25,7 @@ export class UUIDIndexEffects { return new AddToIndexAction( IndexName.OBJECT, action.payload.objectToCache.uuid, - action.payload.objectToCache.self + action.payload.objectToCache._links.self.href ); }) ); @@ -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.reducer.ts b/src/app/core/index/index.reducer.ts index b4cd8aa84b..616363ff7a 100644 --- a/src/app/core/index/index.reducer.ts +++ b/src/app/core/index/index.reducer.ts @@ -126,7 +126,7 @@ function removeFromIndexByValue(state: MetaIndexState, action: RemoveFromIndexBy * @return MetaIndexState * the new state */ -function removeFromIndexBySubstring(state: MetaIndexState, action: RemoveFromIndexByValueAction): MetaIndexState { +function removeFromIndexBySubstring(state: MetaIndexState, action: RemoveFromIndexByValueAction | RemoveFromIndexBySubstringAction): MetaIndexState { const subState = state[action.payload.name]; const newSubState = Object.create(null); for (const value in subState) { 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/integration/integration-response-parsing.service.spec.ts b/src/app/core/integration/integration-response-parsing.service.spec.ts index 4187606265..8cc139744c 100644 --- a/src/app/core/integration/integration-response-parsing.service.spec.ts +++ b/src/app/core/integration/integration-response-parsing.service.spec.ts @@ -1,22 +1,21 @@ -import { ErrorResponse, IntegrationSuccessResponse } from '../cache/response.models'; - -import { ObjectCacheService } from '../cache/object-cache.service'; +import { Store } from '@ngrx/store'; import { GlobalConfig } from '../../../config/global-config.interface'; -import { Store } from '@ngrx/store'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { ErrorResponse, IntegrationSuccessResponse } from '../cache/response.models'; import { CoreState } from '../core.reducers'; -import { IntegrationResponseParsingService } from './integration-response-parsing.service'; -import { IntegrationRequest } from '../data/request.models'; -import { AuthorityValue } from './models/authority.value'; -import { PageInfo } from '../shared/page-info.model'; import { PaginatedList } from '../data/paginated-list'; +import { IntegrationRequest } from '../data/request.models'; +import { PageInfo } from '../shared/page-info.model'; +import { IntegrationResponseParsingService } from './integration-response-parsing.service'; +import { AuthorityValue } from './models/authority.value'; describe('IntegrationResponseParsingService', () => { let service: IntegrationResponseParsingService; const EnvConfig = {} as GlobalConfig; const store = {} as Store; - const objectCacheService = new ObjectCacheService(store); + const objectCacheService = new ObjectCacheService(store, undefined); const name = 'type'; const metadata = 'dc.type'; const query = ''; @@ -33,8 +32,16 @@ describe('IntegrationResponseParsingService', () => { let definitions; function initVars() { - pageInfo = Object.assign(new PageInfo(), { elementsPerPage: 5, totalElements: 5, totalPages: 1, currentPage: 1, self: 'https://rest.api/integration/authorities/type/entries'}); - definitions = new PaginatedList(pageInfo,[ + pageInfo = Object.assign(new PageInfo(), { + elementsPerPage: 5, + totalElements: 5, + totalPages: 1, + currentPage: 1, + _links: { + self: { href: 'https://rest.api/integration/authorities/type/entries' } + } + }); + definitions = new PaginatedList(pageInfo, [ Object.assign(new AuthorityValue(), { type: 'authority', display: 'One', diff --git a/src/app/core/integration/models/authority.resource-type.ts b/src/app/core/integration/models/authority.resource-type.ts new file mode 100644 index 0000000000..ec87ddc85f --- /dev/null +++ b/src/app/core/integration/models/authority.resource-type.ts @@ -0,0 +1,10 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for AuthorityValue + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ + +export const AUTHORITY_VALUE = new ResourceType('authority'); diff --git a/src/app/core/integration/models/authority.value.ts b/src/app/core/integration/models/authority.value.ts index 4c6a7c01cb..4e0183603b 100644 --- a/src/app/core/integration/models/authority.value.ts +++ b/src/app/core/integration/models/authority.value.ts @@ -1,41 +1,59 @@ -import { IntegrationModel } from './integration.model'; +import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; import { isNotEmpty } from '../../../shared/empty.util'; import { PLACEHOLDER_PARENT_METADATA } from '../../../shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.model'; import { OtherInformation } from '../../../shared/form/builder/models/form-field-metadata-value.model'; +import { typedObject } from '../../cache/builders/build-decorators'; +import { HALLink } from '../../shared/hal-link.model'; import { MetadataValueInterface } from '../../shared/metadata.models'; -import { ResourceType } from '../../shared/resource-type'; +import { AUTHORITY_VALUE } from './authority.resource-type'; +import { IntegrationModel } from './integration.model'; /** * Class representing an authority object */ +@typedObject +@inheritSerialization(IntegrationModel) export class AuthorityValue extends IntegrationModel implements MetadataValueInterface { - static type = new ResourceType('authority'); + static type = AUTHORITY_VALUE; /** * The identifier of this authority */ + @autoserialize id: string; /** * The display value of this authority */ + @autoserialize display: string; /** * The value of this authority */ + @autoserialize value: string; /** * An object containing additional information related to this authority */ + @autoserialize otherInformation: OtherInformation; /** * The language code of this authority value */ + @autoserialize language: string; + /** + * The {@link HALLink}s for this AuthorityValue + */ + @deserialize + _links: { + self: HALLink, + }; + /** * This method checks if authority has an identifier value * diff --git a/src/app/core/integration/models/integration.model.ts b/src/app/core/integration/models/integration.model.ts index 3158abc7eb..d2f21a70c0 100644 --- a/src/app/core/integration/models/integration.model.ts +++ b/src/app/core/integration/models/integration.model.ts @@ -1,5 +1,6 @@ -import { autoserialize } from 'cerialize'; +import { autoserialize, deserialize } from 'cerialize'; import { CacheableObject } from '../../cache/object-cache.reducer'; +import { HALLink } from '../../shared/hal-link.model'; export abstract class IntegrationModel implements CacheableObject { @@ -12,9 +13,10 @@ export abstract class IntegrationModel implements CacheableObject { @autoserialize public type: any; - @autoserialize + @deserialize public _links: { - [name: string]: string + self: HALLink, + [name: string]: HALLink } } diff --git a/src/app/core/integration/models/normalized-authority-value.model.ts b/src/app/core/integration/models/normalized-authority-value.model.ts deleted file mode 100644 index 5ebb61281d..0000000000 --- a/src/app/core/integration/models/normalized-authority-value.model.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; -import { IntegrationModel } from './integration.model'; -import { mapsTo } from '../../cache/builders/build-decorators'; -import { AuthorityValue } from './authority.value'; - -/** - * Normalized model class for an Authority Value - */ -@mapsTo(AuthorityValue) -@inheritSerialization(IntegrationModel) -export class NormalizedAuthorityValue extends IntegrationModel { - - @autoserialize - id: string; - - @autoserialize - display: string; - - @autoserialize - value: string; - - @autoserialize - otherInformation: any; - - @autoserialize - language: string; - -} diff --git a/src/app/core/metadata/metadata-field.model.ts b/src/app/core/metadata/metadata-field.model.ts index 45ac4b2051..ad7ec59b25 100644 --- a/src/app/core/metadata/metadata-field.model.ts +++ b/src/app/core/metadata/metadata-field.model.ts @@ -1,44 +1,69 @@ -import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; +import { autoserialize, deserialize } from 'cerialize'; import { isNotEmpty } from '../../shared/empty.util'; -import { MetadataSchema } from './metadata-schema.model'; -import { ResourceType } from '../shared/resource-type'; +import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; +import { link, typedObject } from '../cache/builders/build-decorators'; import { GenericConstructor } from '../shared/generic-constructor'; +import { HALLink } from '../shared/hal-link.model'; +import { HALResource } from '../shared/hal-resource.model'; +import { ResourceType } from '../shared/resource-type'; +import { excludeFromEquals } from '../utilities/equals.decorators'; +import { METADATA_FIELD } from './metadata-field.resource-type'; +import { MetadataSchema } from './metadata-schema.model'; /** * Class the represents a metadata field */ -export class MetadataField extends ListableObject { - static type = new ResourceType('metadatafield'); +@typedObject +export class MetadataField extends ListableObject implements HALResource { + static type = METADATA_FIELD; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; /** * The identifier of this metadata field */ + @autoserialize id: number; - /** - * The self link of this metadata field - */ - self: string; - /** * The element of this metadata field */ + @autoserialize element: string; /** * The qualifier of this metadata field */ + @autoserialize qualifier: string; /** * The scope note of this metadata field */ + @autoserialize scopeNote: string; /** - * The metadata schema object of this metadata field + * The {@link HALLink}s for this MetadataField */ - schema: MetadataSchema; + @deserialize + _links: { + self: HALLink, + schema: HALLink + }; + + /** + * The MetadataSchema for this MetadataField + * Will be undefined unless the schema {@link HALLink} has been resolved. + */ + // TODO the responseparsingservice assumes schemas are always embedded. This should use remotedata, and be a link instead. + // @link(METADATA_SCHEMA) + schema?: MetadataSchema; /** * Method to print this metadata field as a string diff --git a/src/app/core/metadata/metadata-field.resource-type.ts b/src/app/core/metadata/metadata-field.resource-type.ts new file mode 100644 index 0000000000..53cbedb1eb --- /dev/null +++ b/src/app/core/metadata/metadata-field.resource-type.ts @@ -0,0 +1,10 @@ +import { ResourceType } from '../shared/resource-type'; + +/** + * The resource type for MetadataField + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ + +export const METADATA_FIELD = new ResourceType('metadatafield'); diff --git a/src/app/core/metadata/metadata-schema.model.ts b/src/app/core/metadata/metadata-schema.model.ts index 2059b21094..d4d94b8780 100644 --- a/src/app/core/metadata/metadata-schema.model.ts +++ b/src/app/core/metadata/metadata-schema.model.ts @@ -1,33 +1,50 @@ +import { autoserialize, deserialize } from 'cerialize'; import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; -import { ResourceType } from '../shared/resource-type'; +import { typedObject } from '../cache/builders/build-decorators'; import { GenericConstructor } from '../shared/generic-constructor'; +import { HALLink } from '../shared/hal-link.model'; +import { HALResource } from '../shared/hal-resource.model'; +import { ResourceType } from '../shared/resource-type'; +import { excludeFromEquals } from '../utilities/equals.decorators'; +import { METADATA_SCHEMA } from './metadata-schema.resource-type'; /** * Class that represents a metadata schema */ -export class MetadataSchema extends ListableObject { - static type = new ResourceType('metadataschema'); +@typedObject +export class MetadataSchema extends ListableObject implements HALResource { + static type = METADATA_SCHEMA; /** * The unique identifier for this metadata schema */ + @autoserialize id: number; /** - * The REST link to itself + * The object type */ - self: string; + @excludeFromEquals + @autoserialize + type: ResourceType; /** * A unique prefix that defines this schema */ + @autoserialize prefix: string; /** * The namespace of this metadata schema */ + @autoserialize namespace: string; + @deserialize + _links: { + self: HALLink, + }; + /** * Method that returns as which type of object this object should be rendered */ diff --git a/src/app/core/metadata/metadata-schema.resource-type.ts b/src/app/core/metadata/metadata-schema.resource-type.ts new file mode 100644 index 0000000000..462c9957c7 --- /dev/null +++ b/src/app/core/metadata/metadata-schema.resource-type.ts @@ -0,0 +1,10 @@ +import { ResourceType } from '../shared/resource-type'; + +/** + * The resource type for MetadataSchema + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ + +export const METADATA_SCHEMA = new ResourceType('metadataschema'); diff --git a/src/app/core/metadata/metadata.service.spec.ts b/src/app/core/metadata/metadata.service.spec.ts index 80ce33b370..e3f6c3401c 100644 --- a/src/app/core/metadata/metadata.service.spec.ts +++ b/src/app/core/metadata/metadata.service.spec.ts @@ -1,44 +1,58 @@ -import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; -import { RouterTestingModule } from '@angular/router/testing'; - import { CommonModule, Location } from '@angular/common'; +import { HttpClient } from '@angular/common/http'; import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { Meta, MetaDefinition, Title } from '@angular/platform-browser'; import { ActivatedRoute, Router } from '@angular/router'; - -import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { RouterTestingModule } from '@angular/router/testing'; import { Store, StoreModule } from '@ngrx/store'; -import { Observable, of as observableOf } from 'rxjs'; -import { UUIDService } from '../shared/uuid.service'; -import { MetadataService } from './metadata.service'; - -import { CoreState } from '../core.reducers'; - -import { GlobalConfig } from '../../../config/global-config.interface'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { EmptyError } from 'rxjs/internal-compatibility'; import { ENV_CONFIG, GLOBAL_CONFIG } from '../../../config'; -import { ItemDataService } from '../data/item-data.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { RequestService } from '../data/request.service'; +import { GlobalConfig } from '../../../config/global-config.interface'; import { RemoteData } from '../../core/data/remote-data'; import { Item } from '../../core/shared/item.model'; -import { MockItem } from '../../shared/mocks/mock-item'; +import { + MockBitstream1, + MockBitstream2, + MockBitstreamFormat1, + MockBitstreamFormat2, + MockItem +} from '../../shared/mocks/mock-item'; import { MockTranslateLoader } from '../../shared/mocks/mock-translate-loader'; -import { BrowseService } from '../browse/browse.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { AuthService } from '../auth/auth.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { HttpClient } from '@angular/common/http'; -import { EmptyError } from 'rxjs/internal-compatibility'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; -import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; -import { MetadataValue } from '../shared/metadata.models'; import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { AuthService } from '../auth/auth.service'; +import { BrowseService } from '../browse/browse.service'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; + +import { CoreState } from '../core.reducers'; +import { BitstreamDataService } from '../data/bitstream-data.service'; +import { BitstreamFormatDataService } from '../data/bitstream-format-data.service'; +import { CommunityDataService } from '../data/community-data.service'; +import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service'; +import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; + +import { ItemDataService } from '../data/item-data.service'; +import { PaginatedList } from '../data/paginated-list'; +import { FindListOptions } from '../data/request.models'; +import { RequestService } from '../data/request.service'; +import { BitstreamFormat } from '../shared/bitstream-format.model'; +import { Bitstream } from '../shared/bitstream.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { MetadataValue } from '../shared/metadata.models'; +import { PageInfo } from '../shared/page-info.model'; +import { UUIDService } from '../shared/uuid.service'; + +import { MetadataService } from './metadata.service'; /* tslint:disable:max-classes-per-file */ @Component({ @@ -50,13 +64,15 @@ class TestComponent { } } -@Component({ template: '' }) class DummyItemComponent { +@Component({ template: '' }) +class DummyItemComponent { constructor(private route: ActivatedRoute, private items: ItemDataService, private metadata: MetadataService) { this.route.params.subscribe((params) => { this.metadata.processRemoteData(this.items.findById(params.id)); }); } } + /* tslint:enable:max-classes-per-file */ describe('MetadataService', () => { @@ -88,10 +104,33 @@ describe('MetadataService', () => { store = new Store(undefined, undefined, undefined); spyOn(store, 'dispatch'); - objectCacheService = new ObjectCacheService(store); + objectCacheService = new ObjectCacheService(store, undefined); uuidService = new UUIDService(); requestService = new RequestService(objectCacheService, uuidService, store, undefined); - remoteDataBuildService = new RemoteDataBuildService(objectCacheService, requestService); + remoteDataBuildService = new RemoteDataBuildService(objectCacheService, undefined, requestService); + const mockBitstreamDataService = { + findAllByItemAndBundleName(item: Item, bundleName: string, options?: FindListOptions, ...linksToFollow: Array>): Observable>> { + if (item.equals(MockItem)) { + return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [MockBitstream1, MockBitstream2])); + } else { + return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])); + } + }, + }; + const mockBitstreamFormatDataService = { + findByBitstream(bitstream: Bitstream): Observable> { + switch (bitstream) { + case MockBitstream1: + return createSuccessfulRemoteDataObject$(MockBitstreamFormat1); + break; + case MockBitstream2: + return createSuccessfulRemoteDataObject$(MockBitstreamFormat2); + break; + default: + return createSuccessfulRemoteDataObject$(new BitstreamFormat()); + } + } + }; TestBed.configureTestingModule({ imports: [ @@ -105,7 +144,12 @@ describe('MetadataService', () => { }), RouterTestingModule.withRoutes([ { path: 'items/:id', component: DummyItemComponent, pathMatch: 'full' }, - { path: 'other', component: DummyItemComponent, pathMatch: 'full', data: { title: 'Dummy Title', description: 'This is a dummy item component for testing!' } } + { + path: 'other', + component: DummyItemComponent, + pathMatch: 'full', + data: { title: 'Dummy Title', description: 'This is a dummy item component for testing!' } + } ]) ], declarations: [ @@ -121,8 +165,11 @@ describe('MetadataService', () => { { provide: AuthService, useValue: {} }, { provide: NotificationsService, useValue: {} }, { provide: HttpClient, useValue: {} }, - { provide: NormalizedObjectBuildService, useValue: {} }, { provide: DSOChangeAnalyzer, useValue: {} }, + { provide: CommunityDataService, useValue: {} }, + { provide: DefaultChangeAnalyzer, useValue: {} }, + { provide: BitstreamFormatDataService, useValue: mockBitstreamFormatDataService }, + { provide: BitstreamDataService, useValue: mockBitstreamDataService }, Meta, Title, ItemDataService, @@ -193,7 +240,8 @@ describe('MetadataService', () => { describe('when the item has no bitstreams', () => { beforeEach(() => { - spyOn(MockItem, 'getFiles').and.returnValue(observableOf([])); + // this.bitstreamDataService.findAllByItemAndBundleName(this.item, 'ORIGINAL') + // spyOn(MockItem, 'getFiles').and.returnValue(observableOf([])); }); it('processRemoteData should not produce an EmptyError', fakeAsync(() => { @@ -212,7 +260,7 @@ describe('MetadataService', () => { const mockType = (mockItem: Item, type: string): Item => { const typedMockItem = Object.assign(new Item(), mockItem) as Item; - typedMockItem.metadata['dc.type'] = [ { value: type } ] as MetadataValue[]; + typedMockItem.metadata['dc.type'] = [{ value: type }] as MetadataValue[]; return typedMockItem; }; diff --git a/src/app/core/metadata/metadata.service.ts b/src/app/core/metadata/metadata.service.ts index 2b1cf4ffc1..dbba9d83f6 100644 --- a/src/app/core/metadata/metadata.service.ts +++ b/src/app/core/metadata/metadata.service.ts @@ -1,29 +1,26 @@ -import { - catchError, - distinctUntilKeyChanged, - filter, - first, - map, - take -} from 'rxjs/operators'; import { Inject, Injectable } from '@angular/core'; -import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { Meta, MetaDefinition, Title } from '@angular/platform-browser'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { BehaviorSubject, Observable } from 'rxjs'; - -import { RemoteData } from '../data/remote-data'; -import { Bitstream } from '../shared/bitstream.model'; -import { CacheableObject } from '../cache/object-cache.reducer'; -import { DSpaceObject } from '../shared/dspace-object.model'; -import { Item } from '../shared/item.model'; +import { catchError, distinctUntilKeyChanged, filter, first, map, take } from 'rxjs/operators'; import { GLOBAL_CONFIG, GlobalConfig } from '../../../config'; -import { BitstreamFormat } from '../shared/bitstream-format.model'; 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'; + +import { RemoteData } from '../data/remote-data'; +import { BitstreamFormat } from '../shared/bitstream-format.model'; +import { Bitstream } from '../shared/bitstream.model'; +import { DSpaceObject } from '../shared/dspace-object.model'; +import { Item } from '../shared/item.model'; +import { getFirstSucceededRemoteDataPayload, getFirstSucceededRemoteListPayload } from '../shared/operators'; @Injectable() export class MetadataService { @@ -39,6 +36,9 @@ 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 ) { // TODO: determine what open graph meta tags are needed and whether @@ -156,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); } @@ -266,8 +266,9 @@ export class MetadataService { private setCitationPdfUrlTag(): void { if (this.currentObject.value instanceof Item) { const item = this.currentObject.value as Item; - item.getFiles() + this.bitstreamDataService.findAllByItemAndBundleName(item, 'ORIGINAL') .pipe( + getFirstSucceededRemoteListPayload(), first((files) => isNotEmpty(files)), catchError((error) => { console.debug(error.message); @@ -275,19 +276,13 @@ export class MetadataService { })) .subscribe((bitstreams: Bitstream[]) => { for (const bitstream of bitstreams) { - bitstream.format.pipe( - first(), - catchError((error: Error) => { - console.debug(error.message); - return [] - }), - map((rd: RemoteData) => rd.payload), - filter((format: BitstreamFormat) => hasValue(format))) - .subscribe((format: BitstreamFormat) => { - if (format.mimetype === 'application/pdf') { - this.addMetaTag('citation_pdf_url', bitstream.content); - } - }); + this.bitstreamFormatDataService.findByBitstream(bitstream).pipe( + getFirstSucceededRemoteDataPayload() + ).subscribe((format: BitstreamFormat) => { + if (format.mimetype === 'application/pdf') { + this.addMetaTag('citation_pdf_url', bitstream._links.content.href); + } + }); } }); } @@ -367,7 +362,7 @@ export class MetadataService { public clearMetaTags() { this.tagStore.forEach((tags: MetaDefinition[], property: string) => { - this.meta.removeTag("property='" + property + "'"); + this.meta.removeTag('property=\'' + property + '\''); }); this.tagStore.clear(); } diff --git a/src/app/core/metadata/normalized-metadata-field.model.ts b/src/app/core/metadata/normalized-metadata-field.model.ts deleted file mode 100644 index 3d8750778d..0000000000 --- a/src/app/core/metadata/normalized-metadata-field.model.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; -import { mapsTo, relationship } from '../cache/builders/build-decorators'; -import { MetadataField } from './metadata-field.model'; -import { NormalizedObject } from '../cache/models/normalized-object.model'; -import { MetadataSchema } from './metadata-schema.model'; - -/** - * Class the represents a normalized metadata field - */ -@mapsTo(MetadataField) -@inheritSerialization(NormalizedObject) -export class NormalizedMetadataField extends NormalizedObject { - - /** - * The identifier of this normalized metadata field - */ - @autoserialize - id: number; - - /** - * The self link of this normalized metadata field - */ - @autoserialize - self: string; - - /** - * The element of this normalized metadata field - */ - @autoserialize - element: string; - - /** - * The qualifier of this normalized metadata field - */ - @autoserialize - qualifier: string; - - /** - * The scope note of this normalized metadata field - */ - @autoserialize - scopeNote: string; - - /** - * The link to the metadata schema of this normalized metadata field - */ - @deserialize - @relationship(MetadataSchema) - schema: string; -} diff --git a/src/app/core/metadata/normalized-metadata-schema.model.ts b/src/app/core/metadata/normalized-metadata-schema.model.ts deleted file mode 100644 index 4b534725f4..0000000000 --- a/src/app/core/metadata/normalized-metadata-schema.model.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; -import { NormalizedObject } from '../cache/models/normalized-object.model'; -import { mapsTo } from '../cache/builders/build-decorators'; -import { MetadataSchema } from './metadata-schema.model'; - -/** - * Normalized class for a DSpace MetadataSchema - */ -@mapsTo(MetadataSchema) -@inheritSerialization(NormalizedObject) -export class NormalizedMetadataSchema extends NormalizedObject { - /** - * The unique identifier for this schema - */ - @autoserialize - id: number; - - /** - * The REST link to itself - */ - @autoserialize - self: string; - - /** - * A unique prefix that defines this schema - */ - @autoserialize - prefix: string; - - /** - * The namespace for this schema - */ - @autoserialize - namespace: string; - -} diff --git a/src/app/core/registry/registry-bitstreamformats-response.model.ts b/src/app/core/registry/registry-bitstreamformats-response.model.ts index ddf926f3be..4da30b4ffc 100644 --- a/src/app/core/registry/registry-bitstreamformats-response.model.ts +++ b/src/app/core/registry/registry-bitstreamformats-response.model.ts @@ -1,16 +1,24 @@ import { autoserialize, deserialize } from 'cerialize'; +import { BITSTREAM_FORMAT } from '../shared/bitstream-format.resource-type'; +import { HALLink } from '../shared/hal-link.model'; import { PageInfo } from '../shared/page-info.model'; import { BitstreamFormat } from '../shared/bitstream-format.model'; -import { relationship } from '../cache/builders/build-decorators'; +import { link } from '../cache/builders/build-decorators'; export class RegistryBitstreamformatsResponse { - @deserialize - @relationship(BitstreamFormat, true) - bitstreamformats: BitstreamFormat[]; - @autoserialize page: PageInfo; - @autoserialize - self: string; + /** + * The {@link HALLink}s for this RegistryBitstreamformatsResponse + */ + @deserialize + _links: { + self: HALLink; + bitstreamformats: HALLink; + }; + + @link(BITSTREAM_FORMAT) + bitstreamformats?: BitstreamFormat[]; + } diff --git a/src/app/core/registry/registry-metadatafields-response.model.ts b/src/app/core/registry/registry-metadatafields-response.model.ts index 984603e42e..5dc492ab0f 100644 --- a/src/app/core/registry/registry-metadatafields-response.model.ts +++ b/src/app/core/registry/registry-metadatafields-response.model.ts @@ -1,20 +1,30 @@ -import { PageInfo } from '../shared/page-info.model'; import { autoserialize, deserialize } from 'cerialize'; -import { ResourceType } from '../shared/resource-type'; -import { relationship } from '../cache/builders/build-decorators'; -import { NormalizedMetadataField } from '../metadata/normalized-metadata-field.model'; +import { typedObject } from '../cache/builders/build-decorators'; import { MetadataField } from '../metadata/metadata-field.model'; +import { METADATA_FIELD } from '../metadata/metadata-field.resource-type'; +import { HALLink } from '../shared/hal-link.model'; +import { PageInfo } from '../shared/page-info.model'; +import { ResourceType } from '../shared/resource-type'; +import { excludeFromEquals } from '../utilities/equals.decorators'; /** * Class that represents a response with a registry's metadata fields */ +@typedObject export class RegistryMetadatafieldsResponse { - static type = new ResourceType('metadatafield'); + static type = METADATA_FIELD; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + /** * List of metadata fields in the response */ @deserialize - @relationship(MetadataField, true) metadatafields: MetadataField[]; /** @@ -28,4 +38,9 @@ export class RegistryMetadatafieldsResponse { */ @autoserialize self: string; + + @deserialize + _links: { + self: HALLink, + } } diff --git a/src/app/core/registry/registry-metadataschemas-response.model.ts b/src/app/core/registry/registry-metadataschemas-response.model.ts index fc53b354a5..7a485d8849 100644 --- a/src/app/core/registry/registry-metadataschemas-response.model.ts +++ b/src/app/core/registry/registry-metadataschemas-response.model.ts @@ -1,11 +1,9 @@ import { PageInfo } from '../shared/page-info.model'; import { autoserialize, deserialize } from 'cerialize'; import { MetadataSchema } from '../metadata/metadata-schema.model'; -import { relationship } from '../cache/builders/build-decorators'; export class RegistryMetadataschemasResponse { @deserialize - @relationship(MetadataSchema, true) metadataschemas: MetadataSchema[]; @autoserialize diff --git a/src/app/core/registry/registry.service.spec.ts b/src/app/core/registry/registry.service.spec.ts index 03a7c132de..b466693649 100644 --- a/src/app/core/registry/registry.service.spec.ts +++ b/src/app/core/registry/registry.service.spec.ts @@ -1,30 +1,10 @@ -import { TestBed } from '@angular/core/testing'; -import { RegistryService } from './registry.service'; import { CommonModule } from '@angular/common'; -import { RequestService } from '../data/request.service'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; -import { RequestEntry } from '../data/request.reducer'; -import { RemoteData } from '../data/remote-data'; -import { PageInfo } from '../shared/page-info.model'; -import { getMockRequestService } from '../../shared/mocks/mock-request.service'; - -import { - RegistryMetadatafieldsSuccessResponse, - RegistryMetadataschemasSuccessResponse, - RestResponse -} from '../cache/response.models'; import { Component } from '@angular/core'; -import { RegistryMetadataschemasResponse } from './registry-metadataschemas-response.model'; -import { RegistryMetadatafieldsResponse } from './registry-metadatafields-response.model'; -import { map } from 'rxjs/operators'; +import { TestBed } from '@angular/core/testing'; import { Store, StoreModule } from '@ngrx/store'; -import { MockStore } from '../../shared/testing/mock-store'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub'; import { TranslateModule } from '@ngx-translate/core'; +import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; +import { map } from 'rxjs/operators'; import { MetadataRegistryCancelFieldAction, MetadataRegistryCancelSchemaAction, @@ -37,12 +17,31 @@ import { MetadataRegistrySelectFieldAction, MetadataRegistrySelectSchemaAction } from '../../+admin/admin-registries/metadata-registry/metadata-registry.actions'; -import { ResourceType } from '../shared/resource-type'; -import { MetadataSchema } from '../metadata/metadata-schema.model'; -import { MetadataField } from '../metadata/metadata-field.model'; +import { getMockRequestService } from '../../shared/mocks/mock-request.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; +import { MockStore } from '../../shared/testing/mock-store'; +import { NotificationsServiceStub } from '../../shared/testing/notifications-service-stub'; import { createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -@Component({template: ''}) +import { + RegistryMetadatafieldsSuccessResponse, + RegistryMetadataschemasSuccessResponse, + RestResponse +} from '../cache/response.models'; +import { RemoteData } from '../data/remote-data'; +import { RequestEntry } from '../data/request.reducer'; +import { RequestService } from '../data/request.service'; +import { MetadataField } from '../metadata/metadata-field.model'; +import { MetadataSchema } from '../metadata/metadata-schema.model'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { PageInfo } from '../shared/page-info.model'; +import { RegistryMetadatafieldsResponse } from './registry-metadatafields-response.model'; +import { RegistryMetadataschemasResponse } from './registry-metadataschemas-response.model'; +import { RegistryService } from './registry.service'; + +@Component({ template: '' }) class DummyComponent { } @@ -57,15 +56,18 @@ describe('RegistryService', () => { const mockSchemasList = [ Object.assign(new MetadataSchema(), { id: 1, - self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadataschemas/1', + _links: { + self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadataschemas/1' } + }, prefix: 'dc', namespace: 'http://dublincore.org/documents/dcmi-terms/', type: MetadataSchema.type -}), + }), Object.assign(new MetadataSchema(), { - id: 2, - self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadataschemas/2', + _links: { + self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadataschemas/2' } + }, prefix: 'mock', namespace: 'http://dspace.org/mockschema', type: MetadataSchema.type @@ -73,45 +75,53 @@ describe('RegistryService', () => { ]; const mockFieldsList = [ Object.assign(new MetadataField(), - { - id: 1, - self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/8', - element: 'contributor', - qualifier: 'advisor', - scopeNote: null, - schema: mockSchemasList[0], - type: MetadataField.type - }), + { + id: 1, + _links: { + self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/8' } + }, + element: 'contributor', + qualifier: 'advisor', + scopeNote: null, + schema: mockSchemasList[0], + type: MetadataField.type + }), Object.assign(new MetadataField(), { - id: 2, - self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/9', - element: 'contributor', - qualifier: 'author', - scopeNote: null, - schema: mockSchemasList[0], - type: MetadataField.type - }), + id: 2, + _links: { + self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/9' } + }, + element: 'contributor', + qualifier: 'author', + scopeNote: null, + schema: mockSchemasList[0], + type: MetadataField.type + }), Object.assign(new MetadataField(), { - id: 3, - self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/10', - element: 'contributor', - qualifier: 'editor', - scopeNote: 'test scope note', - schema: mockSchemasList[1], - type: MetadataField.type - }), + id: 3, + _links: { + self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/10' } + }, + element: 'contributor', + qualifier: 'editor', + scopeNote: 'test scope note', + schema: mockSchemasList[1], + type: MetadataField.type + }), Object.assign(new MetadataField(), { - id: 4, - self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/11', - element: 'contributor', - qualifier: 'illustrator', - scopeNote: null, - schema: mockSchemasList[1], - type: MetadataField.type - }) + id: 4, + _links: { + self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/11' } + }, + element: 'contributor', + qualifier: 'illustrator', + scopeNote: null, + schema: mockSchemasList[1], + type: MetadataField.type + }) ]; const pageInfo = new PageInfo(); @@ -130,7 +140,7 @@ describe('RegistryService', () => { toRemoteDataObservable: (requestEntryObs: Observable, payloadObs: Observable) => { return observableCombineLatest(requestEntryObs, payloadObs).pipe(map(([req, pay]) => { - return {req, pay}; + return { req, pay }; }) ); }, @@ -146,11 +156,11 @@ describe('RegistryService', () => { DummyComponent ], providers: [ - {provide: RequestService, useValue: getMockRequestService()}, - {provide: RemoteDataBuildService, useValue: rdbStub}, - {provide: HALEndpointService, useValue: halServiceStub}, - {provide: Store, useClass: MockStore}, - {provide: NotificationsService, useValue: new NotificationsServiceStub()}, + { provide: RequestService, useValue: getMockRequestService() }, + { provide: RemoteDataBuildService, useValue: rdbStub }, + { provide: HALEndpointService, useValue: halServiceStub }, + { provide: Store, useClass: MockStore }, + { provide: NotificationsService, useValue: new NotificationsServiceStub() }, RegistryService ] }); @@ -165,7 +175,7 @@ describe('RegistryService', () => { page: pageInfo }); const response = new RegistryMetadataschemasSuccessResponse(queryResponse, 200, 'OK', pageInfo); - const responseEntry = Object.assign(new RequestEntry(), {response: response}); + const responseEntry = Object.assign(new RequestEntry(), { response: response }); beforeEach(() => { (registryService as any).requestService.getByHref.and.returnValue(observableOf(responseEntry)); @@ -194,7 +204,7 @@ describe('RegistryService', () => { page: pageInfo }); const response = new RegistryMetadataschemasSuccessResponse(queryResponse, 200, 'OK', pageInfo); - const responseEntry = Object.assign(new RequestEntry(), {response: response}); + const responseEntry = Object.assign(new RequestEntry(), { response: response }); beforeEach(() => { (registryService as any).requestService.getByHref.and.returnValue(observableOf(responseEntry)); @@ -223,7 +233,7 @@ describe('RegistryService', () => { page: pageInfo }); const response = new RegistryMetadatafieldsSuccessResponse(queryResponse, 200, 'OK', pageInfo); - const responseEntry = Object.assign(new RequestEntry(), {response: response}); + const responseEntry = Object.assign(new RequestEntry(), { response: response }); beforeEach(() => { (registryService as any).requestService.getByHref.and.returnValue(observableOf(responseEntry)); diff --git a/src/app/core/registry/registry.service.ts b/src/app/core/registry/registry.service.ts index 3c6de36492..fbc42b26f4 100644 --- a/src/app/core/registry/registry.service.ts +++ b/src/app/core/registry/registry.service.ts @@ -2,6 +2,7 @@ import { combineLatest as observableCombineLatest, Observable } from 'rxjs'; import { Injectable } from '@angular/core'; import { RemoteData } from '../data/remote-data'; import { PaginatedList } from '../data/paginated-list'; +import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; import { PageInfo } from '../shared/page-info.model'; import { CreateMetadataFieldRequest, @@ -48,8 +49,6 @@ import { MetadataRegistrySelectSchemaAction } from '../../+admin/admin-registries/metadata-registry/metadata-registry.actions'; import { distinctUntilChanged, flatMap, map, take, tap } from 'rxjs/operators'; -import { DSpaceRESTv2Serializer } from '../dspace-rest-v2/dspace-rest-v2.serializer'; -import { NormalizedMetadataSchema } from '../metadata/normalized-metadata-schema.model'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { NotificationOptions } from '../../shared/notifications/models/notification-options.model'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; @@ -57,7 +56,7 @@ import { HttpHeaders } from '@angular/common/http'; import { TranslateService } from '@ngx-translate/core'; import { MetadataSchema } from '../metadata/metadata-schema.model'; import { MetadataField } from '../metadata/metadata-field.model'; -import { getMapsToType } from '../cache/builders/build-decorators'; +import { getClassForType } from '../cache/builders/build-decorators'; const metadataRegistryStateSelector = (state: AppState) => state.metadataRegistry; const editMetadataSchemaSelector = createSelector(metadataRegistryStateSelector, (metadataState: MetadataRegistryState) => metadataState.editSchema); @@ -400,7 +399,7 @@ export class RegistryService { distinctUntilChanged() ); - const serializedSchema = new DSpaceRESTv2Serializer(getMapsToType(MetadataSchema.type)).serialize(schema); + const serializedSchema = new DSpaceSerializer(getClassForType(MetadataSchema.type)).serialize(schema); const request$ = endpoint$.pipe( take(1), diff --git a/src/app/core/services/route.actions.ts b/src/app/core/services/route.actions.ts index 1f6381d2c6..1d3381e2ec 100644 --- a/src/app/core/services/route.actions.ts +++ b/src/app/core/services/route.actions.ts @@ -162,4 +162,5 @@ export type RouteActions = | AddQueryParameterAction | AddParameterAction | ResetRouteStateAction - | SetParameterAction; + | SetParameterAction + | SetQueryParameterAction; 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/bitstream-format.model.ts b/src/app/core/shared/bitstream-format.model.ts index 0e1279e978..8aeba1e3cd 100644 --- a/src/app/core/shared/bitstream-format.model.ts +++ b/src/app/core/shared/bitstream-format.model.ts @@ -1,52 +1,69 @@ -import { CacheableObject, TypedObject } from '../cache/object-cache.reducer'; -import { ResourceType } from './resource-type'; +import { autoserialize, deserialize, deserializeAs } from 'cerialize'; +import { typedObject } from '../cache/builders/build-decorators'; +import { IDToUUIDSerializer } from '../cache/id-to-uuid-serializer'; +import { CacheableObject } from '../cache/object-cache.reducer'; +import { excludeFromEquals } from '../utilities/equals.decorators'; import { BitstreamFormatSupportLevel } from './bitstream-format-support-level'; +import { BITSTREAM_FORMAT } from './bitstream-format.resource-type'; +import { HALLink } from './hal-link.model'; +import { ResourceType } from './resource-type'; /** * Model class for a Bitstream Format */ +@typedObject export class BitstreamFormat implements CacheableObject { - static type = new ResourceType('bitstreamformat'); + static type = BITSTREAM_FORMAT; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; - bitstreamformat /** * Short description of this Bitstream Format */ + @autoserialize shortDescription: string; /** * Description of this Bitstream Format */ + @autoserialize description: string; /** * String representing the MIME type of this Bitstream Format */ + @autoserialize mimetype: string; /** * The level of support the system offers for this Bitstream Format */ + @autoserialize supportLevel: BitstreamFormatSupportLevel; /** * True if the Bitstream Format is used to store system information, rather than the content of items in the system */ + @autoserialize internal: boolean; /** * String representing this Bitstream Format's file extension */ + @autoserialize extensions: string[]; - /** - * The link to the rest endpoint where this Bitstream Format can be found - */ - self: string; - /** * Universally unique identifier for this Bitstream Format + * This UUID is generated client-side and isn't used by the backend. + * It is based on the ID, so it will be the same for each refresh. */ + @deserializeAs(new IDToUUIDSerializer('bitstream-format'), 'id') uuid: string; /** @@ -54,6 +71,14 @@ export class BitstreamFormat implements CacheableObject { * Note that this ID is unique for bitstream formats, * but might not be unique across different object types */ + @autoserialize id: string; + /** + * The {@link HALLink}s for this BitstreamFormat + */ + @deserialize + _links: { + self: HALLink; + } } diff --git a/src/app/core/shared/bitstream-format.resource-type.ts b/src/app/core/shared/bitstream-format.resource-type.ts new file mode 100644 index 0000000000..b1184e2665 --- /dev/null +++ b/src/app/core/shared/bitstream-format.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for BitstreamFormat + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const BITSTREAM_FORMAT = new ResourceType('bitstreamformat'); diff --git a/src/app/core/shared/bitstream.model.ts b/src/app/core/shared/bitstream.model.ts index 887f7d0843..ab9d1548b7 100644 --- a/src/app/core/shared/bitstream.model.ts +++ b/src/app/core/shared/bitstream.model.ts @@ -1,45 +1,60 @@ -import { DSpaceObject } from './dspace-object.model'; -import { RemoteData } from '../data/remote-data'; -import { Item } from './item.model'; -import { BitstreamFormat } from './bitstream-format.model'; +import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; import { Observable } from 'rxjs'; -import { ResourceType } from './resource-type'; +import { link, typedObject } from '../cache/builders/build-decorators'; +import { RemoteData } from '../data/remote-data'; +import { BitstreamFormat } from './bitstream-format.model'; +import { BITSTREAM_FORMAT } from './bitstream-format.resource-type'; +import { BITSTREAM } from './bitstream.resource-type'; +import { DSpaceObject } from './dspace-object.model'; +import { HALLink } from './hal-link.model'; +import { HALResource } from './hal-resource.model'; -export class Bitstream extends DSpaceObject { - static type = new ResourceType('bitstream'); +@typedObject +@inheritSerialization(DSpaceObject) +export class Bitstream extends DSpaceObject implements HALResource { + static type = BITSTREAM; /** * The size of this bitstream in bytes */ + @autoserialize sizeBytes: number; /** * The description of this Bitstream */ + @autoserialize description: string; /** * The name of the Bundle this Bitstream is part of */ + @autoserialize bundleName: string; /** - * An array of Bitstream Format of this Bitstream + * The {@link HALLink}s for this Bitstream */ - format: Observable>; + @deserialize + _links: { + self: HALLink; + bundle: HALLink; + format: HALLink; + content: HALLink; + }; /** - * An array of Items that are direct parents of this Bitstream + * The thumbnail for this Bitstream + * Needs to be resolved first, but isn't available as a {@link HALLink} yet + * Use BitstreamDataService.getThumbnailFor(…) for now. */ - parents: Observable>; + thumbnail?: Observable>; /** - * The Bundle that owns this Bitstream + * The BitstreamFormat of this Bitstream + * Will be undefined unless the format {@link HALLink} has been resolved. */ - owner: Observable>; + @link(BITSTREAM_FORMAT, false, 'format') + format?: Observable>; - /** - * The URL to retrieve this Bitstream's file - */ - content: string; } diff --git a/src/app/core/shared/bitstream.resource-type.ts b/src/app/core/shared/bitstream.resource-type.ts new file mode 100644 index 0000000000..d2ff21ae60 --- /dev/null +++ b/src/app/core/shared/bitstream.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for Bitstream + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const BITSTREAM = new ResourceType('bitstream'); diff --git a/src/app/core/shared/browse-definition.model.ts b/src/app/core/shared/browse-definition.model.ts index 9fafe7e321..e1d0e0bf01 100644 --- a/src/app/core/shared/browse-definition.model.ts +++ b/src/app/core/shared/browse-definition.model.ts @@ -1,10 +1,22 @@ -import { autoserialize, autoserializeAs } from 'cerialize'; -import { SortOption } from './sort-option.model'; -import { ResourceType } from './resource-type'; +import { autoserialize, autoserializeAs, deserialize } from 'cerialize'; +import { typedObject } from '../cache/builders/build-decorators'; import { TypedObject } from '../cache/object-cache.reducer'; +import { excludeFromEquals } from '../utilities/equals.decorators'; +import { BROWSE_DEFINITION } from './browse-definition.resource-type'; +import { HALLink } from './hal-link.model'; +import { ResourceType } from './resource-type'; +import { SortOption } from './sort-option.model'; +@typedObject export class BrowseDefinition implements TypedObject { - static type = new ResourceType('browse'); + static type = BROWSE_DEFINITION; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; @autoserialize id: string; @@ -21,8 +33,14 @@ export class BrowseDefinition implements TypedObject { @autoserializeAs('metadata') metadataKeys: string[]; - @autoserialize - _links: { - [name: string]: string + get self(): string { + return this._links.self.href; } + + @deserialize + _links: { + self: HALLink; + entries: HALLink; + items: HALLink; + }; } diff --git a/src/app/core/shared/browse-definition.resource-type.ts b/src/app/core/shared/browse-definition.resource-type.ts new file mode 100644 index 0000000000..f79ee1f020 --- /dev/null +++ b/src/app/core/shared/browse-definition.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for BrowseDefinition + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const BROWSE_DEFINITION = new ResourceType('browse'); diff --git a/src/app/core/shared/browse-entry.model.ts b/src/app/core/shared/browse-entry.model.ts index d6074de3f5..b5e971d069 100644 --- a/src/app/core/shared/browse-entry.model.ts +++ b/src/app/core/shared/browse-entry.model.ts @@ -1,37 +1,58 @@ +import { autoserialize, autoserializeAs, deserialize } from 'cerialize'; import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; +import { typedObject } from '../cache/builders/build-decorators'; import { TypedObject } from '../cache/object-cache.reducer'; -import { ResourceType } from './resource-type'; -import { GenericConstructor } from './generic-constructor'; import { excludeFromEquals } from '../utilities/equals.decorators'; +import { BROWSE_ENTRY } from './browse-entry.resource-type'; +import { GenericConstructor } from './generic-constructor'; +import { HALLink } from './hal-link.model'; +import { ResourceType } from './resource-type'; /** * Class object representing a browse entry - * This class is not normalized because browse entries do not have self links */ +@typedObject export class BrowseEntry extends ListableObject implements TypedObject { - static type = new ResourceType('browseEntry'); + static type = BROWSE_ENTRY; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; /** * The authority string of this browse entry */ + @autoserialize authority: string; /** * The value of this browse entry */ + @autoserialize value: string; /** * The language of the value of this browse entry */ + @autoserializeAs('valueLang') language: string; /** * The count of this browse entry */ @excludeFromEquals + @autoserialize count: number; + @deserialize + _links: { + self: HALLink; + entries: HALLink; + }; + /** * Method that returns as which type of object this object should be rendered */ diff --git a/src/app/core/shared/browse-entry.resource-type.ts b/src/app/core/shared/browse-entry.resource-type.ts new file mode 100644 index 0000000000..648f7ee31f --- /dev/null +++ b/src/app/core/shared/browse-entry.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for BrowseEntry + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const BROWSE_ENTRY = new ResourceType('browseEntry'); diff --git a/src/app/core/shared/bundle.model.ts b/src/app/core/shared/bundle.model.ts index dade7d12be..c1164f0fc4 100644 --- a/src/app/core/shared/bundle.model.ts +++ b/src/app/core/shared/bundle.model.ts @@ -1,37 +1,21 @@ +import { deserialize, inheritSerialization } from 'cerialize'; +import { typedObject } from '../cache/builders/build-decorators'; +import { BUNDLE } from './bundle.resource-type'; import { DSpaceObject } from './dspace-object.model'; -import { Bitstream } from './bitstream.model'; -import { Item } from './item.model'; -import { RemoteData } from '../data/remote-data'; -import { Observable } from 'rxjs'; -import { ResourceType } from './resource-type'; -import { PaginatedList } from '../data/paginated-list'; +import { HALLink } from './hal-link.model'; +@typedObject +@inheritSerialization(DSpaceObject) export class Bundle extends DSpaceObject { - static type = new ResourceType('bundle'); + static type = BUNDLE; /** - * The bundle's name + * The {@link HALLink}s for this Bundle */ - name: string; - - /** - * The primary bitstream of this Bundle - */ - primaryBitstream: Observable>; - - /** - * An array of Items that are direct parents of this Bundle - */ - parents: Observable>; - - /** - * The Item that owns this Bundle - */ - owner: Observable>; - - /** - * List of Bitstreams that are part of this Bundle - */ - bitstreams: Observable>>; - + @deserialize + _links: { + self: HALLink; + primaryBitstream: HALLink; + bitstreams: HALLink; + } } diff --git a/src/app/core/shared/bundle.resource-type.ts b/src/app/core/shared/bundle.resource-type.ts new file mode 100644 index 0000000000..18c2f1c1b9 --- /dev/null +++ b/src/app/core/shared/bundle.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for Bundle + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const BUNDLE = new ResourceType('bundle'); 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 642fe50736..4e0b5ead83 100644 --- a/src/app/core/shared/collection.model.ts +++ b/src/app/core/shared/collection.model.ts @@ -1,21 +1,83 @@ -import { DSpaceObject } from './dspace-object.model'; -import { Bitstream } from './bitstream.model'; -import { Item } from './item.model'; -import { RemoteData } from '../data/remote-data'; +import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; import { Observable } from 'rxjs'; -import { License } from './license.model'; -import { ResourcePolicy } from './resource-policy.model'; +import { link, typedObject } from '../cache/builders/build-decorators'; import { PaginatedList } from '../data/paginated-list'; -import { ResourceType } from './resource-type'; +import { RemoteData } from '../data/remote-data'; +import { Bitstream } from './bitstream.model'; +import { BITSTREAM } from './bitstream.resource-type'; +import { COLLECTION } from './collection.resource-type'; +import { DSpaceObject } from './dspace-object.model'; +import { HALLink } from './hal-link.model'; +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'; +import { GROUP } from '../eperson/models/group.resource-type'; +import { Group } from '../eperson/models/group.model'; -export class Collection extends DSpaceObject { - static type = new ResourceType('collection'); +@typedObject +@inheritSerialization(DSpaceObject) +export class Collection extends DSpaceObject implements ChildHALResource { + static type = COLLECTION; /** * A string representing the unique handle of this Collection */ + @autoserialize handle: string; + /** + * The {@link HALLink}s for this Collection + */ + @deserialize + _links: { + license: HALLink; + harvester: HALLink; + mappedItems: HALLink; + itemtemplate: HALLink; + defaultAccessConditions: HALLink; + logo: HALLink; + parentCommunity: HALLink; + self: HALLink; + }; + + /** + * The license for this Collection + * Will be undefined unless the license {@link HALLink} has been resolved. + */ + @link(LICENSE) + license?: Observable>; + + /** + * The logo for this Collection + * Will be undefined unless the logo {@link HALLink} has been resolved. + */ + @link(BITSTREAM) + logo?: Observable>; + + /** + * The default access conditions for this Collection + * Will be undefined unless the defaultAccessConditions {@link HALLink} has been resolved. + */ + @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 administrators group of this community. + */ + @link(GROUP) + adminGroup?: Observable>; + /** * The introductory text of this Collection * Corresponds to the metadata field dc.description @@ -56,30 +118,7 @@ export class Collection extends DSpaceObject { return this.firstMetadataValue('dc.description.tableofcontents'); } - /** - * The deposit license of this Collection - */ - license: Observable>; - - /** - * The Bitstream that represents the logo of this Collection - */ - logo: Observable>; - - /** - * The default access conditions of this Collection - */ - defaultAccessConditions: Observable>>; - - /** - * An array of Collections that are direct parents of this Collection - */ - parents: Observable>; - - /** - * The Collection that owns this Collection - */ - owner: Observable>; - - items: Observable>; + getParentLinkKey(): keyof this['_links'] { + return 'parentCommunity'; + } } diff --git a/src/app/core/shared/collection.resource-type.ts b/src/app/core/shared/collection.resource-type.ts new file mode 100644 index 0000000000..899b33f7d2 --- /dev/null +++ b/src/app/core/shared/collection.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for Collection + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const COLLECTION = new ResourceType('collection'); diff --git a/src/app/core/shared/community.model.ts b/src/app/core/shared/community.model.ts index b61ddfd7f9..bdcda70e9b 100644 --- a/src/app/core/shared/community.model.ts +++ b/src/app/core/shared/community.model.ts @@ -1,19 +1,77 @@ -import { DSpaceObject } from './dspace-object.model'; -import { Bitstream } from './bitstream.model'; -import { Collection } from './collection.model'; -import { RemoteData } from '../data/remote-data'; +import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; import { Observable } from 'rxjs'; +import { link, typedObject } from '../cache/builders/build-decorators'; import { PaginatedList } from '../data/paginated-list'; -import { ResourceType } from './resource-type'; +import { RemoteData } from '../data/remote-data'; +import { Group } from '../eperson/models/group.model'; +import { GROUP } from '../eperson/models/group.resource-type'; +import { Bitstream } from './bitstream.model'; +import { BITSTREAM } from './bitstream.resource-type'; +import { Collection } from './collection.model'; +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'; -export class Community extends DSpaceObject { - static type = new ResourceType('community'); +@typedObject +@inheritSerialization(DSpaceObject) +export class Community extends DSpaceObject implements ChildHALResource { + static type = COMMUNITY; /** * A string representing the unique handle of this Community */ + @autoserialize handle: string; + /** + * The {@link HALLink}s for this Community + */ + @deserialize + _links: { + collections: HALLink; + logo: HALLink; + subcommunities: HALLink; + parentCommunity: HALLink; + adminGroup: HALLink; + self: HALLink; + }; + + /** + * The logo for this Community + * Will be undefined unless the logo {@link HALLink} has been resolved. + */ + @link(BITSTREAM) + logo?: Observable>; + + /** + * The list of Collections that are direct children of this Community + * Will be undefined unless the collections {@link HALLink} has been resolved. + */ + @link(COLLECTION, true) + collections?: Observable>>; + + /** + * The list of Communities that are direct children of this Community + * Will be undefined unless the subcommunities {@link HALLink} has been resolved. + */ + @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 administrators group of this community. + */ + @link(GROUP) + adminGroup?: Observable>; + /** * The introductory text of this Community * Corresponds to the metadata field dc.description @@ -46,23 +104,7 @@ export class Community extends DSpaceObject { return this.firstMetadataValue('dc.description.tableofcontents'); } - /** - * The Bitstream that represents the logo of this Community - */ - logo: Observable>; - - /** - * An array of Communities that are direct parents of this Community - */ - parents: Observable>; - - /** - * The Community that owns this Community - */ - owner: Observable>; - - collections: Observable>>; - - subcommunities: Observable>>; - + getParentLinkKey(): keyof this['_links'] { + return 'parentCommunity'; + } } diff --git a/src/app/core/shared/community.resource-type.ts b/src/app/core/shared/community.resource-type.ts new file mode 100644 index 0000000000..2d5f74cafc --- /dev/null +++ b/src/app/core/shared/community.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for Community + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const COMMUNITY = new ResourceType('community'); diff --git a/src/app/core/shared/content-source.model.ts b/src/app/core/shared/content-source.model.ts new file mode 100644 index 0000000000..3e530b6a3a --- /dev/null +++ b/src/app/core/shared/content-source.model.ts @@ -0,0 +1,64 @@ +import { autoserialize, autoserializeAs, deserializeAs, deserialize } from 'cerialize'; +import { HALLink } from './hal-link.model'; +import { HALResource } from './hal-resource.model'; +import { MetadataConfig } from './metadata-config.model'; + +/** + * The type of content harvesting used + */ +export enum ContentSourceHarvestType { + None = 'NONE', + Metadata = 'METADATA_ONLY', + MetadataAndRef = 'METADATA_AND_REF', + MetadataAndBitstreams = 'METADATA_AND_BITSTREAMS' +} + +/** + * A model class that holds information about the Content Source of a Collection + */ +export class ContentSource implements HALResource { + /** + * Unique identifier, this is necessary to store the ContentSource in FieldUpdates + * Because the ContentSource coming from the REST API doesn't have a UUID, we're using the selflink + */ + @deserializeAs('self') + uuid: string; + + /** + * OAI Provider / Source + */ + @autoserializeAs('oai_source') + oaiSource: string; + + /** + * OAI Specific set ID + */ + @autoserializeAs('oai_set_id') + oaiSetId: string; + + /** + * The ID of the metadata format used + */ + @autoserializeAs('metadata_config_id') + metadataConfigId: string; + + /** + * Type of content being harvested + * Defaults to 'NONE', meaning the collection doesn't harvest its content from an external source + */ + @autoserializeAs('harvest_type') + harvestType = ContentSourceHarvestType.None; + + /** + * The available metadata configurations + */ + metadataConfigs: MetadataConfig[]; + + /** + * The {@link HALLink}s for this ContentSource + */ + @deserialize + _links: { + self: HALLink + } +} 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 4fec28d246..a9256fbb7f 100644 --- a/src/app/core/shared/dspace-object.model.ts +++ b/src/app/core/shared/dspace-object.model.ts @@ -1,54 +1,75 @@ -import { Observable } from 'rxjs'; - +import { autoserialize, autoserializeAs, deserialize, deserializeAs } from 'cerialize'; +import { hasNoValue, hasValue, isUndefined } from '../../shared/empty.util'; +import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; +import { typedObject } from '../cache/builders/build-decorators'; +import { CacheableObject } from '../cache/object-cache.reducer'; +import { excludeFromEquals } from '../utilities/equals.decorators'; +import { DSPACE_OBJECT } from './dspace-object.resource-type'; +import { GenericConstructor } from './generic-constructor'; +import { HALLink } from './hal-link.model'; import { MetadataMap, + MetadataMapSerializer, MetadataValue, MetadataValueFilter, MetadatumViewModel } from './metadata.models'; import { Metadata } from './metadata.utils'; -import { hasNoValue, isUndefined } from '../../shared/empty.util'; -import { CacheableObject } from '../cache/object-cache.reducer'; -import { RemoteData } from '../data/remote-data'; -import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; -import { excludeFromEquals } from '../utilities/equals.decorators'; import { ResourceType } from './resource-type'; -import { GenericConstructor } from './generic-constructor'; /** * An abstract model class for a DSpaceObject. */ +@typedObject export class DSpaceObject extends ListableObject implements CacheableObject { /** * A string representing the kind of DSpaceObject, e.g. community, item, … */ - static type = new ResourceType('dspaceobject'); + static type = DSPACE_OBJECT; @excludeFromEquals + @deserializeAs('name') private _name: string; - @excludeFromEquals - self: string; - /** * The human-readable identifier of this DSpaceObject */ @excludeFromEquals + @autoserializeAs(String, 'uuid') id: string; /** * The universally unique identifier of this DSpaceObject */ + @autoserializeAs(String) uuid: string; /** * A string representing the kind of DSpaceObject, e.g. community, item, … */ @excludeFromEquals + @autoserialize type: ResourceType; + /** + * A shorthand to get this DSpaceObject's self link + */ + get self(): string { + return this._links.self.href; + } + + /** + * A shorthand to set this DSpaceObject's self link + */ + set self(v: string) { + this._links.self = { + href: v + }; + } + /** * The name for this DSpaceObject + * @deprecated use {@link DSONameService} instead */ get name(): string { return (isUndefined(this._name)) ? this.firstMetadataValue('dc.title') : this._name; @@ -58,6 +79,9 @@ export class DSpaceObject extends ListableObject implements CacheableObject { * The name for this DSpaceObject */ set name(name) { + if (hasValue(this.firstMetadata('dc.title'))) { + this.firstMetadata('dc.title').value = name; + } this._name = name; } @@ -65,8 +89,14 @@ export class DSpaceObject extends ListableObject implements CacheableObject { * All metadata of this DSpaceObject */ @excludeFromEquals + @autoserializeAs(MetadataMapSerializer) metadata: MetadataMap; + @deserialize + _links: { + self: HALLink; + }; + /** * Retrieve the current metadata as a list of MetadatumViewModels */ @@ -74,18 +104,6 @@ export class DSpaceObject extends ListableObject implements CacheableObject { return Metadata.toViewModelList(this.metadata); } - /** - * An array of DSpaceObjects that are direct parents of this DSpaceObject - */ - @excludeFromEquals - parents: Observable>; - - /** - * The DSpaceObject that owns this DSpaceObject - */ - @excludeFromEquals - owner: Observable>; - /** * Gets all matching metadata in this DSpaceObject. * diff --git a/src/app/core/shared/dspace-object.resource-type.ts b/src/app/core/shared/dspace-object.resource-type.ts new file mode 100644 index 0000000000..7d2b445070 --- /dev/null +++ b/src/app/core/shared/dspace-object.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for DSpaceObject + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const DSPACE_OBJECT = new ResourceType('dspaceobject'); diff --git a/src/app/core/shared/external-source-entry.model.ts b/src/app/core/shared/external-source-entry.model.ts index be52f96b07..5836a01138 100644 --- a/src/app/core/shared/external-source-entry.model.ts +++ b/src/app/core/shared/external-source-entry.model.ts @@ -1,38 +1,64 @@ -import { MetadataMap } from './metadata.models'; -import { ResourceType } from './resource-type'; +import { autoserialize, autoserializeAs, deserialize } from 'cerialize'; import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; +import { typedObject } from '../cache/builders/build-decorators'; +import { excludeFromEquals } from '../utilities/equals.decorators'; +import { EXTERNAL_SOURCE_ENTRY } from './external-source-entry.resource-type'; import { GenericConstructor } from './generic-constructor'; +import { HALLink } from './hal-link.model'; +import { MetadataMap, MetadataMapSerializer } from './metadata.models'; +import { ResourceType } from './resource-type'; /** * Model class for a single entry from an external source */ +@typedObject export class ExternalSourceEntry extends ListableObject { - static type = new ResourceType('externalSourceEntry'); + static type = EXTERNAL_SOURCE_ENTRY; /** * Unique identifier */ + @autoserialize id: string; + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; + /** * The value to display */ + @autoserialize display: string; /** * The value to store the entry with */ + @autoserialize value: string; + /** + * The ID of the external source this entry originates from + */ + @autoserialize + externalSource: string; + /** * Metadata of the entry */ + @autoserializeAs(MetadataMapSerializer) metadata: MetadataMap; /** - * The link to the rest endpoint where this External Source Entry can be found + * The {@link HALLink}s for this ExternalSourceEntry */ - self: string; + @deserialize + _links: { + self: HALLink; + }; /** * Method that returns as which type of object this object should be rendered diff --git a/src/app/core/shared/external-source-entry.resource-type.ts b/src/app/core/shared/external-source-entry.resource-type.ts new file mode 100644 index 0000000000..0fc25a5e3f --- /dev/null +++ b/src/app/core/shared/external-source-entry.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for ResourceType + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const EXTERNAL_SOURCE_ENTRY = new ResourceType('externalSourceEntry'); diff --git a/src/app/core/shared/external-source.model.ts b/src/app/core/shared/external-source.model.ts index a158f18f5d..5005fbcd36 100644 --- a/src/app/core/shared/external-source.model.ts +++ b/src/app/core/shared/external-source.model.ts @@ -1,29 +1,49 @@ -import { ResourceType } from './resource-type'; +import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; +import { typedObject } from '../cache/builders/build-decorators'; import { CacheableObject } from '../cache/object-cache.reducer'; +import { excludeFromEquals } from '../utilities/equals.decorators'; +import { EXTERNAL_SOURCE } from './external-source.resource-type'; +import { HALLink } from './hal-link.model'; +import { ResourceType } from './resource-type'; /** * Model class for an external source */ +@typedObject export class ExternalSource extends CacheableObject { - static type = new ResourceType('externalsource'); + static type = EXTERNAL_SOURCE; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; /** * Unique identifier */ + @autoserialize id: string; /** * The name of this external source */ + @autoserialize name: string; /** * Is the source hierarchical? */ + @autoserialize hierarchical: boolean; /** - * The link to the rest endpoint where this External Source can be found + * The {@link HALLink}s for this ExternalSource */ - self: string; + @deserialize + _links: { + self: HALLink; + entries: HALLink; + } } diff --git a/src/app/core/shared/external-source.resource-type.ts b/src/app/core/shared/external-source.resource-type.ts new file mode 100644 index 0000000000..2cf07bd5fc --- /dev/null +++ b/src/app/core/shared/external-source.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for ExternalSource + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const EXTERNAL_SOURCE = new ResourceType('externalsource'); diff --git a/src/app/core/shared/hal-endpoint.service.spec.ts b/src/app/core/shared/hal-endpoint.service.spec.ts index 8b3011e7d7..cd03b6ec71 100644 --- a/src/app/core/shared/hal-endpoint.service.spec.ts +++ b/src/app/core/shared/hal-endpoint.service.spec.ts @@ -89,7 +89,7 @@ describe('HALEndpointService', () => { .returnValue(hot('a-', { a: 'https://rest.api/test' })); const result = service.getEndpoint(linkPath); - const expected = cold('b-', { b: endpointMap.test }); + const expected = cold('(b|)', { b: endpointMap.test }); expect(result).toBeObservable(expected); }); @@ -97,7 +97,7 @@ describe('HALEndpointService', () => { spyOn(service as any, 'getEndpointAt').and .returnValue(hot('a-', { a: undefined })); const result = service.getEndpoint('unknown'); - const expected = cold('b-', { b: undefined }); + const expected = cold('(b|)', { b: undefined }); expect(result).toBeObservable(expected); }); }); diff --git a/src/app/core/shared/hal-endpoint.service.ts b/src/app/core/shared/hal-endpoint.service.ts index 117cc074ca..530ac086d1 100644 --- a/src/app/core/shared/hal-endpoint.service.ts +++ b/src/app/core/shared/hal-endpoint.service.ts @@ -4,13 +4,14 @@ import { map, mergeMap, startWith, - switchMap, + switchMap, take, tap } from 'rxjs/operators'; +import { RequestEntry } from '../data/request.reducer'; import { RequestService } from '../data/request.service'; import { GlobalConfig } from '../../../config/global-config.interface'; import { EndpointMapRequest } from '../data/request.models'; -import { hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../shared/empty.util'; import { RESTURLCombiner } from '../url-combiner/rest-url-combiner'; import { Inject, Injectable } from '@angular/core'; import { GLOBAL_CONFIG } from '../../../config'; @@ -36,7 +37,11 @@ export class HALEndpointService { private getEndpointMapAt(href): Observable { const request = new EndpointMapRequest(this.requestService.generateRequestId(), href); - this.requestService.configure(request); + if (!this.requestService.isCachedOrPending(request)) { + // don't bother configuring the request if it's already cached or pending. + this.requestService.configure(request); + } + return this.requestService.getByHref(request.href).pipe( getResponseFromEntry(), map((response: EndpointMapSuccessResponse) => response.endpointMap), @@ -44,7 +49,7 @@ export class HALEndpointService { } public getEndpoint(linkPath: string, startHref?: string): Observable { - return this.getEndpointAt(startHref || this.getRootHref(), ...linkPath.split('/')); + return this.getEndpointAt(startHref || this.getRootHref(), ...linkPath.split('/')).pipe(take(1)); } /** @@ -71,10 +76,11 @@ export class HALEndpointService { ) as Observable; if (halNames.length === 1) { - return nextHref$; + return nextHref$.pipe(take(1)); } else { return nextHref$.pipe( - switchMap((nextHref) => this.getEndpointAt(nextHref, ...halNames.slice(1))) + switchMap((nextHref) => this.getEndpointAt(nextHref, ...halNames.slice(1))), + take(1) ); } } diff --git a/src/app/core/shared/hal-link.model.ts b/src/app/core/shared/hal-link.model.ts new file mode 100644 index 0000000000..88a136a4b2 --- /dev/null +++ b/src/app/core/shared/hal-link.model.ts @@ -0,0 +1,23 @@ +/** + * A single link in the _links section of a {@link HALResource} + */ +export class HALLink { + + /** + * The url of the {@link HALLink}'s target + */ + href: string; + + /** + * The name of the {@link HALLink} + */ + name?: string; + + /** + * A boolean indicating whether the href contains a template. + * + * e.g. if href is "http://haltalk.herokuapp.com/docs/{rel}" + * {rel} would be the template + */ + templated?: boolean +} diff --git a/src/app/core/shared/hal-resource.model.ts b/src/app/core/shared/hal-resource.model.ts new file mode 100644 index 0000000000..b6ef822a23 --- /dev/null +++ b/src/app/core/shared/hal-resource.model.ts @@ -0,0 +1,23 @@ +import { HALLink } from './hal-link.model'; + +/** + * Represents HAL resources. + * + * A HAL resource has a _links section with at least a self link. + */ +export class HALResource { + /** + * The {@link HALLink}s for this {@link HALResource} + */ + _links: { + /** + * The {@link HALLink} that refers to this {@link HALResource} + */ + self: HALLink + + /** + * {@link HALLink}s to related {@link HALResource}s + */ + [k: string]: HALLink; + }; +} diff --git a/src/app/core/shared/item-relationships/item-type.model.ts b/src/app/core/shared/item-relationships/item-type.model.ts index 0fc52b00a5..d41024cdaa 100644 --- a/src/app/core/shared/item-relationships/item-type.model.ts +++ b/src/app/core/shared/item-relationships/item-type.model.ts @@ -1,26 +1,48 @@ +import { autoserialize, deserialize, deserializeAs } from 'cerialize'; +import { typedObject } from '../../cache/builders/build-decorators'; +import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer'; import { CacheableObject } from '../../cache/object-cache.reducer'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { HALLink } from '../hal-link.model'; import { ResourceType } from '../resource-type'; +import { ITEM_TYPE } from './item-type.resource-type'; /** * Describes a type of Item */ +@typedObject export class ItemType implements CacheableObject { - static type = new ResourceType('entitytype'); + static type = ITEM_TYPE; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; /** * The identifier of this ItemType */ + @autoserialize id: string; + @autoserialize label: string; /** - * The link to the rest endpoint where this object can be found + * The universally unique identifier of this ItemType + * This UUID is generated client-side and isn't used by the backend. + * It is based on the ID, so it will be the same for each refresh. */ - self: string; + @deserializeAs(new IDToUUIDSerializer(ItemType.type.value), 'id') + uuid: string; /** - * The universally unique identifier of this ItemType + * The {@link HALLink}s for this ItemType */ - uuid: string; + @deserialize + _links: { + self: HALLink, + }; } diff --git a/src/app/core/shared/item-relationships/item-type.resource-type.ts b/src/app/core/shared/item-relationships/item-type.resource-type.ts new file mode 100644 index 0000000000..616dc23b73 --- /dev/null +++ b/src/app/core/shared/item-relationships/item-type.resource-type.ts @@ -0,0 +1,10 @@ +import { ResourceType } from '../resource-type'; + +/** + * The resource type for ItemType + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ + +export const ITEM_TYPE = new ResourceType('entitytype'); diff --git a/src/app/core/shared/item-relationships/relationship-type.model.ts b/src/app/core/shared/item-relationships/relationship-type.model.ts index 06ac94b041..fb62f685dd 100644 --- a/src/app/core/shared/item-relationships/relationship-type.model.ts +++ b/src/app/core/shared/item-relationships/relationship-type.model.ts @@ -1,72 +1,107 @@ +import { autoserialize, deserialize, deserializeAs } from 'cerialize'; import { Observable } from 'rxjs'; +import { link, typedObject } from '../../cache/builders/build-decorators'; +import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer'; import { CacheableObject } from '../../cache/object-cache.reducer'; import { RemoteData } from '../../data/remote-data'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { HALLink } from '../hal-link.model'; import { ResourceType } from '../resource-type'; import { ItemType } from './item-type.model'; +import { ITEM_TYPE } from './item-type.resource-type'; +import { RELATIONSHIP_TYPE } from './relationship-type.resource-type'; /** * Describes a type of Relationship between multiple possible Items */ +@typedObject export class RelationshipType implements CacheableObject { - static type = new ResourceType('relationshiptype'); + static type = RELATIONSHIP_TYPE; /** - * The link to the rest endpoint where this object can be found + * The object type */ - self: string; + @excludeFromEquals + @autoserialize + type: ResourceType; /** * The label that describes this RelationshipType */ + @autoserialize label: string; /** * The identifier of this RelationshipType */ + @autoserialize id: string; /** * The universally unique identifier of this RelationshipType + * This UUID is generated client-side and isn't used by the backend. + * It is based on the ID, so it will be the same for each refresh. */ + @deserializeAs(new IDToUUIDSerializer(RelationshipType.type.value), 'id') uuid: string; /** * The label that describes the Relation to the left of this RelationshipType */ + @autoserialize leftwardType: string; /** * The maximum amount of Relationships allowed to the left of this RelationshipType */ + @autoserialize leftMaxCardinality: number; /** * The minimum amount of Relationships allowed to the left of this RelationshipType */ + @autoserialize leftMinCardinality: number; /** * The label that describes the Relation to the right of this RelationshipType */ + @autoserialize rightwardType: string; /** * The maximum amount of Relationships allowed to the right of this RelationshipType */ + @autoserialize rightMaxCardinality: number; /** * The minimum amount of Relationships allowed to the right of this RelationshipType */ + @autoserialize rightMinCardinality: number; /** - * The type of Item found to the left of this RelationshipType + * The {@link HALLink}s for this RelationshipType */ - leftType: Observable>; + @deserialize + _links: { + self: HALLink; + leftType: HALLink; + rightType: HALLink; + }; /** - * The type of Item found to the right of this RelationshipType + * The type of Item found on the left side of this RelationshipType + * Will be undefined unless the leftType {@link HALLink} has been resolved. */ - rightType: Observable>; + @link(ITEM_TYPE) + leftType?: Observable>; + + /** + * The type of Item found on the right side of this RelationshipType + * Will be undefined unless the rightType {@link HALLink} has been resolved. + */ + @link(ITEM_TYPE) + rightType?: Observable>; } diff --git a/src/app/core/shared/item-relationships/relationship-type.resource-type.ts b/src/app/core/shared/item-relationships/relationship-type.resource-type.ts new file mode 100644 index 0000000000..6f6300c38e --- /dev/null +++ b/src/app/core/shared/item-relationships/relationship-type.resource-type.ts @@ -0,0 +1,10 @@ +import { ResourceType } from '../resource-type'; + +/** + * The resource type for RelationshipType + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ + +export const RELATIONSHIP_TYPE = new ResourceType('relationshiptype'); diff --git a/src/app/core/shared/item-relationships/relationship.model.ts b/src/app/core/shared/item-relationships/relationship.model.ts index 2adcf42c04..97a5db9e37 100644 --- a/src/app/core/shared/item-relationships/relationship.model.ts +++ b/src/app/core/shared/item-relationships/relationship.model.ts @@ -1,63 +1,100 @@ +import { autoserialize, deserialize, serialize, deserializeAs } from 'cerialize'; import { Observable } from 'rxjs'; +import { link, typedObject } from '../../cache/builders/build-decorators'; +import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer'; import { CacheableObject } from '../../cache/object-cache.reducer'; import { RemoteData } from '../../data/remote-data'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; +import { HALLink } from '../hal-link.model'; +import { Item } from '../item.model'; +import { ITEM } from '../item.resource-type'; import { ResourceType } from '../resource-type'; import { RelationshipType } from './relationship-type.model'; -import { Item } from '../item.model'; +import { RELATIONSHIP_TYPE } from './relationship-type.resource-type'; +import { RELATIONSHIP } from './relationship.resource-type'; /** * Describes a Relationship between two Items */ +@typedObject export class Relationship implements CacheableObject { - static type = new ResourceType('relationship'); + static type = RELATIONSHIP; /** - * The link to the rest endpoint where this object can be found + * The object type */ - self: string; + @excludeFromEquals + @autoserialize + type: ResourceType; /** * The universally unique identifier of this Relationship + * This UUID is generated client-side and isn't used by the backend. + * It is based on the ID, so it will be the same for each refresh. */ + @deserializeAs(new IDToUUIDSerializer(Relationship.type.value), 'id') uuid: string; /** * The identifier of this Relationship */ + @autoserialize id: string; - /** - * The item to the left of this relationship - */ - leftItem: Observable>; - - /** - * The item to the right of this relationship - */ - rightItem: Observable>; - /** * The place of the Item to the left side of this Relationship */ + @autoserialize leftPlace: number; /** * The place of the Item to the right side of this Relationship */ + @autoserialize rightPlace: number; /** * The name variant of the Item to the left side of this Relationship */ + @autoserialize leftwardValue: string; /** * The name variant of the Item to the right side of this Relationship */ + @autoserialize rightwardValue: string; /** - * The type of Relationship + * The {@link HALLink}s for this Relationship */ - relationshipType: Observable>; + @deserialize + _links: { + self: HALLink; + leftItem: HALLink; + rightItem: HALLink; + relationshipType: HALLink; + }; + + /** + * The item on the left side of this relationship + * Will be undefined unless the leftItem {@link HALLink} has been resolved. + */ + @link(ITEM) + leftItem?: Observable>; + + /** + * The item on the right side of this relationship + * Will be undefined unless the rightItem {@link HALLink} has been resolved. + */ + @link(ITEM) + rightItem?: Observable>; + + /** + * The RelationshipType for this Relationship + * Will be undefined unless the relationshipType {@link HALLink} has been resolved. + */ + @link(RELATIONSHIP_TYPE) + relationshipType?: Observable>; + } diff --git a/src/app/core/shared/item-relationships/relationship.resource-type.ts b/src/app/core/shared/item-relationships/relationship.resource-type.ts new file mode 100644 index 0000000000..f65f218d70 --- /dev/null +++ b/src/app/core/shared/item-relationships/relationship.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../resource-type'; + +/** + * The resource type for Relationship. + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const RELATIONSHIP = new ResourceType('relationship'); diff --git a/src/app/core/shared/item.model.spec.ts b/src/app/core/shared/item.model.spec.ts index 1cffcf568a..9a4e11e6fd 100644 --- a/src/app/core/shared/item.model.spec.ts +++ b/src/app/core/shared/item.model.spec.ts @@ -1,10 +1,6 @@ -import { Observable, of as observableOf } from 'rxjs'; +import { createPaginatedList, createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils'; import { Item } from './item.model'; -import { Bitstream } from './bitstream.model'; -import { isEmpty } from '../../shared/empty.util'; -import { first, map } from 'rxjs/operators'; -import { createPaginatedList, createSuccessfulRemoteDataObject$ } from '../../shared/testing/utils'; describe('Item', () => { @@ -55,50 +51,4 @@ describe('Item', () => { item = Object.assign(new Item(), { bundles: remoteDataBundles }); }); - - it('should return the bitstreams related to this item with the specified bundle name', () => { - const bitObs: Observable = item.getBitstreamsByBundleName(thumbnailBundleName); - bitObs.pipe(first()).subscribe((bs) => - expect(bs.every((b) => b.name === thumbnailBundleName)).toBeTruthy()); - }); - - it('should return an empty array when no bitstreams with this bundleName exist for this item', () => { - const bs: Observable = item.getBitstreamsByBundleName(nonExistingBundleName); - bs.pipe(first()).subscribe((b) => expect(isEmpty(b)).toBeTruthy()); - }); - - describe('get thumbnail', () => { - beforeEach(() => { - spyOn(item, 'getBitstreamsByBundleName').and.returnValue(observableOf([remoteDataThumbnail])); - }); - - it('should return the thumbnail of this item', () => { - const path: string = thumbnailPath; - const bitstream: Observable = item.getThumbnail(); - bitstream.pipe(map((b) => expect(b.content).toBe(path))); - }); - }); - - describe('get files', () => { - beforeEach(() => { - spyOn(item, 'getBitstreamsByBundleName').and.returnValue(observableOf(bitstreams)); - }); - - it("should return all bitstreams with 'ORIGINAL' as bundleName", () => { - const paths = [bitstream1Path, bitstream2Path]; - - const files: Observable = item.getFiles(); - let index = 0; - files.pipe(map((f) => expect(f.length).toBe(2))); - files.subscribe( - (array) => array.forEach( - (file) => { - expect(file.content).toBe(paths[index]); - index++; - } - ) - ) - }); - - }); }); diff --git a/src/app/core/shared/item.model.ts b/src/app/core/shared/item.model.ts index bd304274ab..7f6cf9fe13 100644 --- a/src/app/core/shared/item.model.ts +++ b/src/app/core/shared/item.model.ts @@ -1,122 +1,105 @@ -import { map, startWith, filter, switchMap } from 'rxjs/operators'; -import { Observable } from 'rxjs'; +import { autoserialize, autoserializeAs, deserialize, inheritSerialization } from 'cerialize'; +import { Observable } from 'rxjs/internal/Observable'; +import { isEmpty } from '../../shared/empty.util'; +import { DEFAULT_ENTITY_TYPE } from '../../shared/metadata-representation/metadata-representation.decorator'; +import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; +import { link, typedObject } from '../cache/builders/build-decorators'; +import { PaginatedList } from '../data/paginated-list'; +import { RemoteData } from '../data/remote-data'; +import { Bundle } from './bundle.model'; +import { BUNDLE } from './bundle.resource-type'; +import { Collection } from './collection.model'; +import { COLLECTION } from './collection.resource-type'; import { DSpaceObject } from './dspace-object.model'; -import { Collection } from './collection.model'; -import { RemoteData } from '../data/remote-data'; -import { Bitstream } from './bitstream.model'; -import { hasValueOperator, isNotEmpty, isEmpty } from '../../shared/empty.util'; -import { PaginatedList } from '../data/paginated-list'; -import { Relationship } from './item-relationships/relationship.model'; -import { ResourceType } from './resource-type'; -import { getAllSucceededRemoteData, getSucceededRemoteData } from './operators'; -import { Bundle } from './bundle.model'; import { GenericConstructor } from './generic-constructor'; -import { ListableObject } from '../../shared/object-collection/shared/listable-object.model'; -import { DEFAULT_ENTITY_TYPE } from '../../shared/metadata-representation/metadata-representation.decorator'; +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 */ -export class Item extends DSpaceObject { - static type = new ResourceType('item'); +@typedObject +@inheritSerialization(DSpaceObject) +export class Item extends DSpaceObject implements ChildHALResource { + static type = ITEM; /** * A string representing the unique handle of this Item */ + @autoserialize handle: string; /** * The Date of the last modification of this Item */ + @deserialize lastModified: Date; /** * A boolean representing if this Item is currently archived or not */ + @autoserializeAs(Boolean, 'inArchive') isArchived: boolean; /** * A boolean representing if this Item is currently discoverable or not */ + @autoserializeAs(Boolean, 'discoverable') isDiscoverable: boolean; /** * A boolean representing if this Item is currently withdrawn or not */ + @autoserializeAs(Boolean, 'withdrawn') isWithdrawn: boolean; /** - * An array of Collections that are direct parents of this Item + * The {@link HALLink}s for this Item */ - parents: Observable>; + @deserialize + _links: { + mappedCollections: HALLink; + relationships: HALLink; + bundles: HALLink; + owningCollection: HALLink; + templateItemOf: HALLink; + version: HALLink; + self: HALLink; + }; /** - * The Collection that owns this Item + * The owning Collection for this Item + * Will be undefined unless the owningCollection {@link HALLink} has been resolved. */ - owningCollection: Observable>; - - get owner(): Observable> { - return this.owningCollection; - } + @link(COLLECTION) + owningCollection?: Observable>; /** - * Bitstream bundles within this item + * The version this item represents in its history + * Will be undefined unless the version {@link HALLink} has been resolved. */ - bundles: Observable>>; - - relationships: Observable>>; + @link(VERSION) + version?: Observable>; /** - * Retrieves the thumbnail of this item - * @returns {Observable} the primaryBitstream of the 'THUMBNAIL' bundle + * The list of Bundles inside this Item + * Will be undefined unless the bundles {@link HALLink} has been resolved. */ - getThumbnail(): Observable { - // TODO: currently this just picks the first thumbnail - // should be adjusted when we have a way to determine - // the primary thumbnail from rest - return this.getBitstreamsByBundleName('THUMBNAIL').pipe( - filter((thumbnails) => isNotEmpty(thumbnails)), - map((thumbnails) => thumbnails[0]),) - } + @link(BUNDLE, true) + bundles?: Observable>>; /** - * Retrieves the thumbnail for the given original of this item - * @returns {Observable} the primaryBitstream of the 'THUMBNAIL' bundle + * The list of Relationships this Item has with others + * Will be undefined unless the relationships {@link HALLink} has been resolved. */ - getThumbnailForOriginal(original: Bitstream): Observable { - return this.getBitstreamsByBundleName('THUMBNAIL').pipe( - map((files) => { - return files.find((thumbnail) => thumbnail.name.startsWith(original.name)) - }),startWith(undefined),); - } - - /** - * Retrieves all files that should be displayed on the item page of this item - * @returns {Observable>>} an array of all Bitstreams in the 'ORIGINAL' bundle - */ - getFiles(): Observable { - return this.getBitstreamsByBundleName('ORIGINAL'); - } - - /** - * Retrieves bitstreams by bundle name - * @param bundleName The name of the Bundle that should be returned - * @returns {Observable} the bitstreams with the given bundleName - * TODO now that bitstreams can be paginated this should move to the server - * see https://github.com/DSpace/dspace-angular/issues/332 - */ - getBitstreamsByBundleName(bundleName: string): Observable { - return this.bundles.pipe( - getSucceededRemoteData(), - map((rd: RemoteData>) => rd.payload.page.find((bundle: Bundle) => bundle.name === bundleName)), - hasValueOperator(), - switchMap((bundle: Bundle) => bundle.bitstreams), - getAllSucceededRemoteData(), - map((rd: RemoteData>) => rd.payload.page), - startWith([]) - ); - } + @link(RELATIONSHIP, true) + relationships?: Observable>>; /** * Method that returns as which type of object this object should be rendered @@ -128,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/item.resource-type.ts b/src/app/core/shared/item.resource-type.ts new file mode 100644 index 0000000000..8371f6b9b5 --- /dev/null +++ b/src/app/core/shared/item.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for Item. + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const ITEM = new ResourceType('item'); diff --git a/src/app/core/shared/license.model.ts b/src/app/core/shared/license.model.ts index fa49e1f430..2b2477c1f8 100644 --- a/src/app/core/shared/license.model.ts +++ b/src/app/core/shared/license.model.ts @@ -1,16 +1,22 @@ +import { autoserialize, inheritSerialization } from 'cerialize'; +import { typedObject } from '../cache/builders/build-decorators'; import { DSpaceObject } from './dspace-object.model'; -import { ResourceType } from './resource-type'; +import { LICENSE } from './license.resource-type'; +@typedObject +@inheritSerialization(DSpaceObject) export class License extends DSpaceObject { - static type = new ResourceType('license'); + static type = LICENSE; /** * Is the license custom? */ + @autoserialize custom: boolean; /** * The text of the license */ + @autoserialize text: string; } diff --git a/src/app/core/shared/license.resource-type.ts b/src/app/core/shared/license.resource-type.ts new file mode 100644 index 0000000000..0e53525ac5 --- /dev/null +++ b/src/app/core/shared/license.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for License + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const LICENSE = new ResourceType('license'); diff --git a/src/app/core/shared/metadata-config.model.ts b/src/app/core/shared/metadata-config.model.ts new file mode 100644 index 0000000000..861d04586e --- /dev/null +++ b/src/app/core/shared/metadata-config.model.ts @@ -0,0 +1,19 @@ +/** + * A model class that holds information about a certain metadata configuration + */ +export class MetadataConfig { + /** + * A unique indentifier + */ + id: string; + + /** + * The label used for display + */ + label: string; + + /** + * The namespace of the metadata + */ + nameSpace: string; +} diff --git a/src/app/core/shared/metadata.utils.spec.ts b/src/app/core/shared/metadata.utils.spec.ts index f4b3517649..016ef594b1 100644 --- a/src/app/core/shared/metadata.utils.spec.ts +++ b/src/app/core/shared/metadata.utils.spec.ts @@ -7,6 +7,7 @@ import { MetadatumViewModel } from './metadata.models'; import { Metadata } from './metadata.utils'; +import { beforeEach } from 'selenium-webdriver/testing'; const mdValue = (value: string, language?: string, authority?: string): MetadataValue => { return Object.assign(new MetadataValue(), { uuid: uuidv4(), value: value, language: isUndefined(language) ? null : language, place: 0, authority: isUndefined(authority) ? null : authority, confidence: undefined }); @@ -216,4 +217,26 @@ describe('Metadata', () => { testToMetadataMap(multiViewModelList, multiMap); }); + describe('setFirstValue method', () => { + + const metadataMap = { + 'dc.description': [mdValue('Test description')], + 'dc.title': [mdValue('Test title 1'), mdValue('Test title 2')] + }; + + const testSetFirstValue = (map: MetadataMap, key: string, value: string) => { + describe(`with field ${key} and value ${value}`, () => { + Metadata.setFirstValue(map, key, value); + it(`should set first value of ${key} to ${value}`, () => { + expect(map[key][0].value).toEqual(value); + }); + }); + }; + + testSetFirstValue(metadataMap, 'dc.description', 'New Description'); + testSetFirstValue(metadataMap, 'dc.title', 'New Title'); + testSetFirstValue(metadataMap, 'dc.format', 'Completely new field and value'); + + }); + }); diff --git a/src/app/core/shared/metadata.utils.ts b/src/app/core/shared/metadata.utils.ts index 334c430968..24ff06f4c9 100644 --- a/src/app/core/shared/metadata.utils.ts +++ b/src/app/core/shared/metadata.utils.ts @@ -1,4 +1,4 @@ -import { isEmpty, isNotUndefined, isUndefined } from '../../shared/empty.util'; +import { isEmpty, isNotEmpty, isNotUndefined, isUndefined } from '../../shared/empty.util'; import { MetadataMapInterface, MetadataValue, @@ -217,4 +217,19 @@ export class Metadata { }); return metadataMap; } + + /** + * Set the first value of a metadata by field key + * Creates a new MetadataValue if the field doesn't exist yet + * @param mdMap The map to add/change values in + * @param key The metadata field + * @param value The value to add + */ + public static setFirstValue(mdMap: MetadataMapInterface, key: string, value: string) { + if (isNotEmpty(mdMap[key])) { + mdMap[key][0].value = value; + } else { + mdMap[key] = [Object.assign(new MetadataValue(), { value: value })] + } + } } diff --git a/src/app/core/shared/normalized-browse-entry.model.ts b/src/app/core/shared/normalized-browse-entry.model.ts deleted file mode 100644 index 949758cb67..0000000000 --- a/src/app/core/shared/normalized-browse-entry.model.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; -import { BrowseEntry } from './browse-entry.model'; -import { NormalizedObject } from '../cache/models/normalized-object.model'; -import { mapsTo } from '../cache/builders/build-decorators'; - -/** - * Class object representing a browse entry - * This class is not normalized because browse entries do not have self links - */ -@mapsTo(BrowseEntry) -@inheritSerialization(NormalizedObject) -export class NormalizedBrowseEntry extends NormalizedObject { - /** - * The authority string of this browse entry - */ - @autoserialize - authority: string; - - /** - * The value of this browse entry - */ - @autoserialize - value: string; - - /** - * The language of the value of this browse entry - */ - @autoserializeAs('valueLang') - language: string; - - /** - * The count of this browse entry - */ - @autoserialize - count: number; -} diff --git a/src/app/core/shared/operators.ts b/src/app/core/shared/operators.ts index 308e4f8a2d..a51e711d26 100644 --- a/src/app/core/shared/operators.ts +++ b/src/app/core/shared/operators.ts @@ -1,16 +1,16 @@ +import { Router } from '@angular/router'; import { Observable } from 'rxjs'; import { filter, find, flatMap, map, take, tap } from 'rxjs/operators'; import { hasValue, hasValueOperator, isNotEmpty } from '../../shared/empty.util'; +import { SearchResult } from '../../shared/search/search-result.model'; import { DSOSuccessResponse, RestResponse } from '../cache/response.models'; +import { PaginatedList } from '../data/paginated-list'; import { RemoteData } from '../data/remote-data'; import { RestRequest } from '../data/request.models'; import { RequestEntry } from '../data/request.reducer'; import { RequestService } from '../data/request.service'; import { BrowseDefinition } from './browse-definition.model'; import { DSpaceObject } from './dspace-object.model'; -import { PaginatedList } from '../data/paginated-list'; -import { SearchResult } from '../../shared/search/search-result.model'; -import { Router } from '@angular/router'; /** * This file contains custom RxJS operators that can be used in multiple places @@ -59,10 +59,92 @@ export const getRemoteDataPayload = () => (source: Observable>): Observable => source.pipe(map((remoteData: RemoteData) => remoteData.payload)); +export const getPaginatedListPayload = () => + (source: Observable>): Observable => + source.pipe(map((list: PaginatedList) => list.page)); + export const getSucceededRemoteData = () => (source: Observable>): Observable> => source.pipe(find((rd: RemoteData) => rd.hasSucceeded)); +/** + * Get the first successful remotely retrieved object + * + * You usually don't want to use this, it is a code smell. + * Work with the RemoteData object instead, that way you can + * handle loading and errors correctly. + * + * These operators were created as a first step in refactoring + * out all the instances where this is used incorrectly. + */ +export const getFirstSucceededRemoteDataPayload = () => + (source: Observable>): Observable => + source.pipe( + getSucceededRemoteData(), + getRemoteDataPayload() + ); + +/** + * Get the all successful remotely retrieved objects + * + * You usually don't want to use this, it is a code smell. + * Work with the RemoteData object instead, that way you can + * handle loading and errors correctly. + * + * These operators were created as a first step in refactoring + * out all the instances where this is used incorrectly. + */ +export const getAllSucceededRemoteDataPayload = () => + (source: Observable>): Observable => + source.pipe( + getAllSucceededRemoteData(), + getRemoteDataPayload() + ); + +/** + * Get the first successful remotely retrieved paginated list + * as an array + * + * You usually don't want to use this, it is a code smell. + * Work with the RemoteData object instead, that way you can + * handle loading and errors correctly. + * + * You also don't want to ignore pagination and simply use the + * page as an array. + * + * These operators were created as a first step in refactoring + * out all the instances where this is used incorrectly. + */ +export const getFirstSucceededRemoteListPayload = () => + (source: Observable>>): Observable => + source.pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + getPaginatedListPayload() + ); + +/** + * Get all successful remotely retrieved paginated lists + * as arrays + * + * You usually don't want to use this, it is a code smell. + * Work with the RemoteData object instead, that way you can + * handle loading and errors correctly. + * + * You also don't want to ignore pagination and simply use the + * page as an array. + * + * These operators were created as a first step in refactoring + * out all the instances where this is used incorrectly. + */ +export const getAllSucceededRemoteListPayload = () => + (source: Observable>>): Observable => + source.pipe( + getAllSucceededRemoteData(), + getRemoteDataPayload(), + getPaginatedListPayload() + ); + /** * Operator that checks if a remote data object contains a page not found error * When it does contain such an error, it will redirect the user to a page not found, without altering the current URL @@ -125,3 +207,13 @@ export const getFirstOccurrence = () => source.pipe( map((rd) => Object.assign(rd, { payload: rd.payload.page.length > 0 ? rd.payload.page[0] : undefined })) ); + +/** + * Operator for turning the current page of bitstreams into an array + */ +export const paginatedListToArray = () => + (source: Observable>>): Observable => + source.pipe( + hasValueOperator(), + map((objectRD: RemoteData>) => objectRD.payload.page.filter((object: T) => hasValue(object))) + ); diff --git a/src/app/core/shared/page-info.model.ts b/src/app/core/shared/page-info.model.ts index 273510da60..ccb0aae471 100644 --- a/src/app/core/shared/page-info.model.ts +++ b/src/app/core/shared/page-info.model.ts @@ -1,10 +1,12 @@ -import { autoserialize, autoserializeAs } from 'cerialize'; +import { autoserialize, autoserializeAs, deserialize } from 'cerialize'; import { hasValue } from '../../shared/empty.util'; +import { HALLink } from './hal-link.model'; +import { HALResource } from './hal-resource.model'; /** * Represents the state of a paginated response */ -export class PageInfo { +export class PageInfo implements HALResource { /** * The number of elements on a page @@ -30,20 +32,17 @@ export class PageInfo { @autoserializeAs(Number, 'number') currentPage: number; - @autoserialize - last: string; - - @autoserialize - next: string; - - @autoserialize - prev: string; - - @autoserialize - first: string; - - @autoserialize - self: string; + /** + * The {@link HALLink}s for this PageInfo + */ + @deserialize + _links: { + first: HALLink; + prev: HALLink; + next: HALLink; + last: HALLink; + self: HALLink; + }; constructor( options?: { @@ -60,4 +59,41 @@ export class PageInfo { this.currentPage = options.currentPage; } } + + get self() { + return this._links.self.href; + } + + get last(): string { + if (hasValue(this._links) && hasValue(this._links.last)) { + return this._links.last.href; + } else { + return undefined; + } + } + + get next(): string { + if (hasValue(this._links) && hasValue(this._links.next)) { + return this._links.next.href; + } else { + return undefined; + } + } + + get prev(): string { + if (hasValue(this._links) && hasValue(this._links.prev)) { + return this._links.prev.href; + } else { + return undefined; + } + } + + get first(): string { + if (hasValue(this._links) && hasValue(this._links.first)) { + return this._links.first.href; + } else { + return undefined; + } + } + } diff --git a/src/app/core/shared/resource-policy.model.ts b/src/app/core/shared/resource-policy.model.ts index a80446a369..dd00a16e97 100644 --- a/src/app/core/shared/resource-policy.model.ts +++ b/src/app/core/shared/resource-policy.model.ts @@ -1,36 +1,58 @@ -import { CacheableObject } from '../cache/object-cache.reducer'; -import { ResourceType } from './resource-type'; +import { autoserialize, deserialize, deserializeAs } from 'cerialize'; +import { typedObject } from '../cache/builders/build-decorators'; +import { IDToUUIDSerializer } from '../cache/id-to-uuid-serializer'; import { ActionType } from '../cache/models/action-type.model'; +import { CacheableObject } from '../cache/object-cache.reducer'; +import { excludeFromEquals } from '../utilities/equals.decorators'; +import { HALLink } from './hal-link.model'; +import { RESOURCE_POLICY } from './resource-policy.resource-type'; +import { ResourceType } from './resource-type'; /** * Model class for a Resource Policy */ +@typedObject export class ResourcePolicy implements CacheableObject { - static type = new ResourceType('resourcePolicy'); + static type = RESOURCE_POLICY; + + /** + * The object type + */ + @excludeFromEquals + @autoserialize + type: ResourceType; /** * The action that is allowed by this Resource Policy */ + @autoserialize action: ActionType; /** * The name for this Resource Policy */ + @autoserialize name: string; /** * The uuid of the Group this Resource Policy applies to */ + @autoserialize groupUUID: string; - /** - * The link to the rest endpoint where this Resource Policy can be found - */ - self: string; - /** * The universally unique identifier for this Resource Policy + * This UUID is generated client-side and isn't used by the backend. + * It is based on the ID, so it will be the same for each refresh. */ + @deserializeAs(new IDToUUIDSerializer('resource-policy'), 'id') uuid: string; + /** + * The {@link HALLink}s for this ResourcePolicy + */ + @deserialize + _links: { + self: HALLink, + } } diff --git a/src/app/core/shared/resource-policy.resource-type.ts b/src/app/core/shared/resource-policy.resource-type.ts new file mode 100644 index 0000000000..1811a3a0d1 --- /dev/null +++ b/src/app/core/shared/resource-policy.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for ResourcePolicy + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const RESOURCE_POLICY = new ResourceType('resourcePolicy'); diff --git a/src/app/core/shared/search/search.service.ts b/src/app/core/shared/search/search.service.ts index 141f261990..06dfd6dba0 100644 --- a/src/app/core/shared/search/search.service.ts +++ b/src/app/core/shared/search/search.service.ts @@ -2,6 +2,8 @@ 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, 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'; import { ResponseParsingService } from '../../data/parsing.service'; @@ -13,7 +15,6 @@ import { GenericConstructor } from '../generic-constructor'; import { HALEndpointService } from '../hal-endpoint.service'; import { URLCombiner } from '../../url-combiner/url-combiner'; import { hasValue, isEmpty, isNotEmpty, isNotUndefined } from '../../../shared/empty.util'; -import { NormalizedSearchResult } from '../../../shared/search/normalized-search-result.model'; import { SearchOptions } from '../../../shared/search/search-options.model'; import { SearchResult } from '../../../shared/search/search-result.model'; import { FacetValue } from '../../../shared/search/facet-value.model'; @@ -69,6 +70,7 @@ export class SearchService implements OnDestroy { private routeService: RouteService, protected requestService: RequestService, private rdb: RemoteDataBuildService, + private linkService: LinkService, private halService: HALEndpointService, private communityService: CommunityDataService, private dspaceObjectService: DSpaceObjectDataService @@ -105,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); } /** @@ -149,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), ); @@ -167,19 +171,19 @@ export class SearchService implements OnDestroy { const dsoObs: Observable> = sqrObs.pipe( map((sqr: SearchQueryResponse) => { return sqr.objects - .filter((nsr: NormalizedSearchResult) => isNotUndefined(nsr.indexableObject)) - .map((nsr: NormalizedSearchResult) => new GetRequest(this.requestService.generateRequestId(), nsr.indexableObject)) + .filter((sr: SearchResult) => isNotUndefined(sr._links.indexableObject)) + .map((sr: SearchResult) => new GetRequest(this.requestService.generateRequestId(), sr._links.indexableObject.href)) }), // 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)), ); // Create search results again with the correct dso objects linked to each result const tDomainListObs = observableCombineLatest(sqrObs, dsoObs).pipe( map(([sqr, dsos]) => { - return sqr.objects.map((object: NormalizedSearchResult, index: number) => { + return sqr.objects.map((object: SearchResult, index: number) => { let co = DSpaceObject; if (dsos.payload[index]) { const constructor: GenericConstructor = dsos.payload[index].constructor as GenericConstructor; @@ -340,6 +344,7 @@ export class SearchService implements OnDestroy { switchMap((dsoRD: RemoteData) => { if ((dsoRD.payload as any).type === Community.type.value) { const community: Community = dsoRD.payload as Community; + this.linkService.resolveLinks(community, followLink('subcommunities'), followLink('collections')); return observableCombineLatest(community.subcommunities, community.collections).pipe( map(([subCommunities, collections]) => { /*if this is a community, we also need to show the direct children*/ diff --git a/src/app/core/shared/site.model.ts b/src/app/core/shared/site.model.ts index a191b2143f..befd4c1ae3 100644 --- a/src/app/core/shared/site.model.ts +++ b/src/app/core/shared/site.model.ts @@ -1,11 +1,15 @@ +import { inheritSerialization } from 'cerialize'; +import { typedObject } from '../cache/builders/build-decorators'; import { DSpaceObject } from './dspace-object.model'; -import { ResourceType } from './resource-type'; +import { SITE } from './site.resource-type'; /** * Model class for the Site object */ +@typedObject +@inheritSerialization(DSpaceObject) export class Site extends DSpaceObject { ​ - static type = new ResourceType('site'); + static type = SITE; ​ } diff --git a/src/app/core/shared/site.resource-type.ts b/src/app/core/shared/site.resource-type.ts new file mode 100644 index 0000000000..570697833f --- /dev/null +++ b/src/app/core/shared/site.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from './resource-type'; + +/** + * The resource type for Site + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const SITE = new ResourceType('site'); 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/models/normalized-submission-object.model.ts b/src/app/core/submission/models/normalized-submission-object.model.ts deleted file mode 100644 index f674ebdf72..0000000000 --- a/src/app/core/submission/models/normalized-submission-object.model.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { autoserialize, autoserializeAs, inheritSerialization } from 'cerialize'; - -import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; -import { WorkspaceitemSectionsObject } from './workspaceitem-sections.model'; -import { SubmissionObjectError } from './submission-object.model'; -import { DSpaceObject } from '../../shared/dspace-object.model'; - -/** - * An abstract model class for a NormalizedSubmissionObject. - */ -@inheritSerialization(NormalizedDSpaceObject) -export class NormalizedSubmissionObject extends NormalizedDSpaceObject { - - /** - * The workspaceitem/workflowitem identifier - */ - @autoserialize - id: string; - - /** - * The workspaceitem/workflowitem identifier - */ - @autoserializeAs(String, 'id') - uuid: string; - - /** - * The workspaceitem/workflowitem last modified date - */ - @autoserialize - lastModified: Date; - - /** - * The workspaceitem/workflowitem last sections data - */ - @autoserialize - sections: WorkspaceitemSectionsObject; - - /** - * The workspaceitem/workflowitem last sections errors - */ - @autoserialize - errors: SubmissionObjectError[]; -} diff --git a/src/app/core/submission/models/normalized-workflowitem.model.ts b/src/app/core/submission/models/normalized-workflowitem.model.ts deleted file mode 100644 index e96024b4ae..0000000000 --- a/src/app/core/submission/models/normalized-workflowitem.model.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; - -import { mapsTo, relationship } from '../../cache/builders/build-decorators'; -import { WorkflowItem } from './workflowitem.model'; -import { NormalizedSubmissionObject } from './normalized-submission-object.model'; -import { Collection } from '../../shared/collection.model'; -import { Item } from '../../shared/item.model'; -import { SubmissionDefinitionsModel } from '../../config/models/config-submission-definitions.model'; -import { EPerson } from '../../eperson/models/eperson.model'; - -/** - * An model class for a NormalizedWorkflowItem. - */ -@mapsTo(WorkflowItem) -@inheritSerialization(NormalizedSubmissionObject) -export class NormalizedWorkflowItem extends NormalizedSubmissionObject { - - /** - * The collection this workflowitem belonging to - */ - @autoserialize - @relationship(Collection, false) - collection: string; - - /** - * The item created with this workflowitem - */ - @autoserialize - @relationship(Item, false) - item: string; - - /** - * The configuration object that define this workflowitem - */ - @autoserialize - @relationship(SubmissionDefinitionsModel, false) - submissionDefinition: string; - - /** - * The EPerson who submit this workflowitem - */ - @autoserialize - @relationship(EPerson, false) - submitter: string; - -} diff --git a/src/app/core/submission/models/normalized-workspaceitem.model.ts b/src/app/core/submission/models/normalized-workspaceitem.model.ts deleted file mode 100644 index 4275420191..0000000000 --- a/src/app/core/submission/models/normalized-workspaceitem.model.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; - -import { WorkspaceItem } from './workspaceitem.model'; -import { NormalizedSubmissionObject } from './normalized-submission-object.model'; -import { mapsTo, relationship } from '../../cache/builders/build-decorators'; -import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; -import { Item } from '../../shared/item.model'; -import { Collection } from '../../shared/collection.model'; -import { SubmissionDefinitionModel } from '../../config/models/config-submission-definition.model'; -import { EPerson } from '../../eperson/models/eperson.model'; - -/** - * An model class for a NormalizedWorkspaceItem. - */ -@mapsTo(WorkspaceItem) -@inheritSerialization(NormalizedDSpaceObject) -@inheritSerialization(NormalizedSubmissionObject) -export class NormalizedWorkspaceItem extends NormalizedSubmissionObject { - - /** - * The collection this workspaceitem belonging to - */ - @autoserialize - @relationship(Collection, false) - collection: string; - - /** - * The item created with this workspaceitem - */ - @autoserialize - @relationship(Item, false) - item: string; - - /** - * The configuration object that define this workspaceitem - */ - @autoserialize - @relationship(SubmissionDefinitionModel, false) - submissionDefinition: string; - - /** - * The EPerson who submit this workspaceitem - */ - @autoserialize - @relationship(EPerson, false) - submitter: string; -} diff --git a/src/app/core/submission/models/submission-object.model.ts b/src/app/core/submission/models/submission-object.model.ts index 0b1110fa24..87ea19653d 100644 --- a/src/app/core/submission/models/submission-object.model.ts +++ b/src/app/core/submission/models/submission-object.model.ts @@ -1,12 +1,19 @@ +import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; import { Observable } from 'rxjs'; +import { link } from '../../cache/builders/build-decorators'; import { CacheableObject } from '../../cache/object-cache.reducer'; -import { DSpaceObject } from '../../shared/dspace-object.model'; -import { EPerson } from '../../eperson/models/eperson.model'; -import { RemoteData } from '../../data/remote-data'; -import { Collection } from '../../shared/collection.model'; -import { Item } from '../../shared/item.model'; import { SubmissionDefinitionsModel } from '../../config/models/config-submission-definitions.model'; +import { RemoteData } from '../../data/remote-data'; +import { EPerson } from '../../eperson/models/eperson.model'; +import { EPERSON } from '../../eperson/models/eperson.resource-type'; +import { Collection } from '../../shared/collection.model'; +import { COLLECTION } from '../../shared/collection.resource-type'; +import { DSpaceObject } from '../../shared/dspace-object.model'; +import { HALLink } from '../../shared/hal-link.model'; +import { Item } from '../../shared/item.model'; +import { ITEM } from '../../shared/item.resource-type'; +import { excludeFromEquals } from '../../utilities/equals.decorators'; import { WorkspaceitemSectionsObject } from './workspaceitem-sections.model'; export interface SubmissionObjectError { @@ -17,50 +24,72 @@ export interface SubmissionObjectError { /** * An abstract model class for a SubmissionObject. */ +@inheritSerialization(DSpaceObject) export abstract class SubmissionObject extends DSpaceObject implements CacheableObject { - /** - * The workspaceitem/workflowitem identifier - */ + @excludeFromEquals + @autoserialize id: string; /** - * The workspaceitem/workflowitem identifier - */ - uuid: string; - - /** - * The workspaceitem/workflowitem last modified date + * The SubmissionObject last modified date */ + @autoserialize lastModified: Date; /** * The collection this submission applies to + * Will be undefined unless the collection {@link HALLink} has been resolved. */ - collection: Observable> | Collection; + @link(COLLECTION) + collection?: Observable> | Collection; /** - * The submission item - */ - item: Observable> | Item; - - /** - * The workspaceitem/workflowitem last sections data + * The SubmissionObject's last section's data */ + @autoserialize sections: WorkspaceitemSectionsObject; /** - * The configuration object that define this submission - */ - submissionDefinition: Observable> | SubmissionDefinitionsModel; - - /** - * The workspaceitem submitter - */ - submitter: Observable> | EPerson; - - /** - * The workspaceitem/workflowitem last sections errors + * The SubmissionObject's last section's errors */ + @autoserialize errors: SubmissionObjectError[]; + + /** + * The {@link HALLink}s for this SubmissionObject + */ + @deserialize + _links: { + self: HALLink; + collection: HALLink; + item: HALLink; + submissionDefinition: HALLink; + submitter: HALLink; + }; + + get self(): string { + return this._links.self.href; + } + + /** + * The submission item + * Will be undefined unless the item {@link HALLink} has been resolved. + */ + @link(ITEM) + item?: Observable> | Item; + /** + * The configuration object that define this submission + * Will be undefined unless the submissionDefinition {@link HALLink} has been resolved. + */ + @link(SubmissionDefinitionsModel.type) + submissionDefinition?: Observable> | SubmissionDefinitionsModel; + + /** + * The submitter for this SubmissionObject + * Will be undefined unless the submitter {@link HALLink} has been resolved. + */ + @link(EPERSON) + submitter?: Observable> | EPerson; + } diff --git a/src/app/core/submission/models/workflowitem.model.ts b/src/app/core/submission/models/workflowitem.model.ts index 4cfc4d7fa1..b8054a66d0 100644 --- a/src/app/core/submission/models/workflowitem.model.ts +++ b/src/app/core/submission/models/workflowitem.model.ts @@ -1,9 +1,23 @@ -import { WorkspaceItem } from './workspaceitem.model'; -import { ResourceType } from '../../shared/resource-type'; +import { deserializeAs, inheritSerialization } from 'cerialize'; +import { inheritLinkAnnotations, typedObject } from '../../cache/builders/build-decorators'; +import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer'; +import { WORKFLOWITEM } from '../../eperson/models/workflowitem.resource-type'; +import { SubmissionObject } from './submission-object.model'; /** * A model class for a WorkflowItem. */ -export class WorkflowItem extends WorkspaceItem { - static type = new ResourceType('workflowitem'); +@typedObject +@inheritSerialization(SubmissionObject) +@inheritLinkAnnotations(SubmissionObject) +export class WorkflowItem extends SubmissionObject { + static type = WORKFLOWITEM; + + /** + * The universally unique identifier of this WorkflowItem + * This UUID is generated client-side and isn't used by the backend. + * It is based on the ID, so it will be the same for each refresh. + */ + @deserializeAs(new IDToUUIDSerializer(WorkflowItem.type.value), 'id') + uuid: string; } diff --git a/src/app/core/submission/models/workspaceitem.model.ts b/src/app/core/submission/models/workspaceitem.model.ts index c4bb5b7520..b29d8c0efa 100644 --- a/src/app/core/submission/models/workspaceitem.model.ts +++ b/src/app/core/submission/models/workspaceitem.model.ts @@ -1,10 +1,24 @@ +import { deserializeAs, inheritSerialization } from 'cerialize'; +import { inheritLinkAnnotations, typedObject } from '../../cache/builders/build-decorators'; +import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer'; +import { DSpaceObject } from '../../shared/dspace-object.model'; import { SubmissionObject } from './submission-object.model'; import { ResourceType } from '../../shared/resource-type'; /** * A model class for a WorkspaceItem. */ +@typedObject +@inheritSerialization(SubmissionObject) +@inheritLinkAnnotations(SubmissionObject) export class WorkspaceItem extends SubmissionObject { static type = new ResourceType('workspaceitem'); + /** + * The universally unique identifier of this WorkspaceItem + * This UUID is generated client-side and isn't used by the backend. + * It is based on the ID, so it will be the same for each refresh. + */ + @deserializeAs(new IDToUUIDSerializer(WorkspaceItem.type.value), 'id') + uuid: string; } diff --git a/src/app/core/submission/submission-object-data.service.spec.ts b/src/app/core/submission/submission-object-data.service.spec.ts index b7c06272e6..f46a465edb 100644 --- a/src/app/core/submission/submission-object-data.service.spec.ts +++ b/src/app/core/submission/submission-object-data.service.spec.ts @@ -45,7 +45,7 @@ describe('SubmissionObjectDataService', () => { service = new SubmissionObjectDataService(workspaceitemDataService, workflowItemDataService, submissionService); }); - it('should forward the result of WorkspaceitemDataService.findById()', () => { + it('should forward the result of WorkspaceitemDataService.findByIdAndIDType()', () => { const result = service.findById(submissionId); expect(workspaceitemDataService.findById).toHaveBeenCalledWith(submissionId); expect(result).toBe(wsiResult); @@ -60,7 +60,7 @@ describe('SubmissionObjectDataService', () => { service = new SubmissionObjectDataService(workspaceitemDataService, workflowItemDataService, submissionService); }); - it('should forward the result of WorkflowItemDataService.findById()', () => { + it('should forward the result of WorkflowItemDataService.findByIdAndIDType()', () => { const result = service.findById(submissionId); expect(workflowItemDataService.findById).toHaveBeenCalledWith(submissionId); expect(result).toBe(wfiResult); diff --git a/src/app/core/submission/submission-object-data.service.ts b/src/app/core/submission/submission-object-data.service.ts index 15ede18cb8..0b6d65c758 100644 --- a/src/app/core/submission/submission-object-data.service.ts +++ b/src/app/core/submission/submission-object-data.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core'; import { of as observableOf, Observable } from 'rxjs'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; import { SubmissionService } from '../../submission/submission.service'; import { RemoteData } from '../data/remote-data'; import { RemoteDataError } from '../data/remote-data-error'; @@ -27,13 +28,14 @@ export class SubmissionObjectDataService { * Retrieve a submission object based on its ID. * * @param id The identifier of a submission object + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved */ - findById(id: string): Observable> { + findById(id: string, ...linksToFollow: Array>): Observable> { switch (this.submissionService.getSubmissionScope()) { case SubmissionScopeType.WorkspaceItem: - return this.workspaceitemDataService.findById(id); + return this.workspaceitemDataService.findById(id,...linksToFollow); case SubmissionScopeType.WorkflowItem: - return this.workflowItemDataService.findById(id); + return this.workflowItemDataService.findById(id,...linksToFollow); default: const error = new RemoteDataError( undefined, diff --git a/src/app/core/submission/submission-response-parsing.service.ts b/src/app/core/submission/submission-response-parsing.service.ts index 8bc2971922..27a7e43c46 100644 --- a/src/app/core/submission/submission-response-parsing.service.ts +++ b/src/app/core/submission/submission-response-parsing.service.ts @@ -12,10 +12,10 @@ import { BaseResponseParsingService } from '../data/base-response-parsing.servic import { GLOBAL_CONFIG } from '../../../config'; import { GlobalConfig } from '../../../config/global-config.interface'; import { ObjectCacheService } from '../cache/object-cache.service'; -import { NormalizedWorkspaceItem } from './models/normalized-workspaceitem.model'; -import { NormalizedWorkflowItem } from './models/normalized-workflowitem.model'; import { FormFieldMetadataValueObject } from '../../shared/form/builder/models/form-field-metadata-value.model'; import { SubmissionObject } from './models/submission-object.model'; +import { WorkflowItem } from './models/workflowitem.model'; +import { WorkspaceItem } from './models/workspaceitem.model'; /** * Export a function to check if object has same properties of FormFieldMetadataValueObject @@ -77,6 +77,18 @@ export class SubmissionResponseParsingService extends BaseResponseParsingService protected toCache = false; + /** + * The submission assumes certain related HALResources will always be embedded. + * It only works if the responseparser finds these embedded resources, and directly + * attaches them to the requested object, instead of putting them in the cache and + * treating them as separate objects. This boolean was added to allow us to disable + * that behavior for the rest of the application, while keeping it for the submission. + * + * It should be removed after the submission has been refactored to treat embeds as + * resources that may need to be retrieved separately. + */ + protected shouldDirectlyAttachEmbeds = true; + constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, protected objectCache: ObjectCacheService, protected dsoParser: DSOResponseParsingService @@ -119,15 +131,15 @@ export class SubmissionResponseParsingService extends BaseResponseParsingService */ protected processResponse(data: any, request: RestRequest): any[] { const dataDefinition = this.process(data, request); - const normalizedDefinition = Array.of(); + const definition = Array.of(); const processedList = Array.isArray(dataDefinition) ? dataDefinition : Array.of(dataDefinition); processedList.forEach((item) => { - let normalizedItem = Object.assign({}, item); - // In case data is an Instance of NormalizedWorkspaceItem normalize field value of all the section of type form - if (item instanceof NormalizedWorkspaceItem - || item instanceof NormalizedWorkflowItem) { + item = Object.assign({}, item); + // In case data is an Instance of WorkspaceItem normalize field value of all the section of type form + if (item instanceof WorkspaceItem + || item instanceof WorkflowItem) { if (item.sections) { const precessedSection = Object.create({}); // Iterate over all workspaceitem's sections @@ -137,35 +149,35 @@ export class SubmissionResponseParsingService extends BaseResponseParsingService // When Upload section is disabled, add to submission only if there are files (!item.sections[sectionId].hasOwnProperty('files') || isNotEmpty((item.sections[sectionId] as any).files)))) { - const normalizedSectionData = Object.create({}); + const sectiondata = Object.create({}); // Iterate over all sections property Object.keys(item.sections[sectionId]) .forEach((metdadataId) => { const entry = item.sections[sectionId][metdadataId]; // If entry is not an array, for sure is not a section of type form if (Array.isArray(entry)) { - normalizedSectionData[metdadataId] = []; + sectiondata[metdadataId] = []; entry.forEach((valueItem, index) => { // Parse value and normalize it const normValue = normalizeSectionData(valueItem, index); if (isNotEmpty(normValue)) { - normalizedSectionData[metdadataId].push(normValue); + sectiondata[metdadataId].push(normValue); } }); } else { - normalizedSectionData[metdadataId] = entry; + sectiondata[metdadataId] = entry; } }); - precessedSection[sectionId] = normalizedSectionData; + precessedSection[sectionId] = sectiondata; } }); - normalizedItem = Object.assign({}, item, { sections: precessedSection }); + item = Object.assign({}, item, { sections: precessedSection }); } } - normalizedDefinition.push(normalizedItem); + definition.push(item); }); - return normalizedDefinition; + return definition; } } 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/submission/workflowitem-data.service.ts b/src/app/core/submission/workflowitem-data.service.ts index 47195ed0a1..a2dfca5eb3 100644 --- a/src/app/core/submission/workflowitem-data.service.ts +++ b/src/app/core/submission/workflowitem-data.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; +import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { CoreState } from '../core.reducers'; import { DataService } from '../data/data.service'; @@ -9,7 +10,6 @@ import { RequestService } from '../data/request.service'; import { WorkflowItem } from './models/workflowitem.model'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { FindListOptions } from '../data/request.models'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; @@ -18,13 +18,13 @@ import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; * A service that provides methods to make REST requests with workflowitems endpoint. */ @Injectable() +@dataService(WorkflowItem.type) export class WorkflowItemDataService extends DataService { protected linkPath = 'workflowitems'; protected responseMsToLive = 10 * 1000; constructor( protected comparator: DSOChangeAnalyzer, - protected dataBuildService: NormalizedObjectBuildService, protected halService: HALEndpointService, protected http: HttpClient, protected notificationsService: NotificationsService, @@ -35,8 +35,4 @@ export class WorkflowItemDataService extends DataService { super(); } - public getBrowseEndpoint(options: FindListOptions) { - return this.halService.getEndpoint(this.linkPath); - } - } diff --git a/src/app/core/submission/workspaceitem-data.service.ts b/src/app/core/submission/workspaceitem-data.service.ts index 3f782b74a2..fcb85cc8b4 100644 --- a/src/app/core/submission/workspaceitem-data.service.ts +++ b/src/app/core/submission/workspaceitem-data.service.ts @@ -2,13 +2,13 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; +import { dataService } from '../cache/builders/build-decorators'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { CoreState } from '../core.reducers'; import { DataService } from '../data/data.service'; import { RequestService } from '../data/request.service'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { FindListOptions } from '../data/request.models'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; import { ObjectCacheService } from '../cache/object-cache.service'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; @@ -18,13 +18,13 @@ import { WorkspaceItem } from './models/workspaceitem.model'; * A service that provides methods to make REST requests with workspaceitems endpoint. */ @Injectable() +@dataService(WorkspaceItem.type) export class WorkspaceitemDataService extends DataService { protected linkPath = 'workspaceitems'; protected responseMsToLive = 10 * 1000; constructor( protected comparator: DSOChangeAnalyzer, - protected dataBuildService: NormalizedObjectBuildService, protected halService: HALEndpointService, protected http: HttpClient, protected notificationsService: NotificationsService, @@ -35,8 +35,4 @@ export class WorkspaceitemDataService extends DataService { super(); } - public getBrowseEndpoint(options: FindListOptions) { - return this.halService.getEndpoint(this.linkPath); - } - } diff --git a/src/app/core/tasks/claimed-task-data.service.spec.ts b/src/app/core/tasks/claimed-task-data.service.spec.ts index a7be0830ec..078fe1e63f 100644 --- a/src/app/core/tasks/claimed-task-data.service.spec.ts +++ b/src/app/core/tasks/claimed-task-data.service.spec.ts @@ -6,7 +6,6 @@ import { getMockRequestService } from '../../shared/mocks/mock-request.service'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { CoreState } from '../core.reducers'; import { ClaimedTaskDataService } from './claimed-task-data.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; @@ -22,9 +21,6 @@ describe('ClaimedTaskDataService', () => { const notificationsService = {} as NotificationsService; const http = {} as HttpClient; const comparator = {} as any; - const dataBuildService = { - normalize: (object) => object - } as NormalizedObjectBuildService; const objectCache = { addPatch: () => { /* empty */ @@ -39,7 +35,6 @@ describe('ClaimedTaskDataService', () => { return new ClaimedTaskDataService( requestService, rdbService, - dataBuildService, store, objectCache, halService, @@ -57,8 +52,7 @@ describe('ClaimedTaskDataService', () => { options.headers = headers; }); - describe('approveTask', () => { - + describe('submitTask', () => { it('should call postToEndpoint method', () => { const scopeId = '1234'; const body = { @@ -68,33 +62,13 @@ describe('ClaimedTaskDataService', () => { spyOn(service, 'postToEndpoint'); requestService.uriEncodeBody.and.returnValue(body); - service.approveTask(scopeId); - - expect(service.postToEndpoint).toHaveBeenCalledWith(linkPath, body, scopeId, options); - }); - }); - - describe('rejectTask', () => { - - it('should call postToEndpoint method', () => { - const scopeId = '1234'; - const reason = 'test reject'; - const body = { - submit_reject: 'true', - reason - }; - - spyOn(service, 'postToEndpoint'); - requestService.uriEncodeBody.and.returnValue(body); - - service.rejectTask(reason, scopeId); + service.submitTask(scopeId, body); expect(service.postToEndpoint).toHaveBeenCalledWith(linkPath, body, scopeId, options); }); }); describe('returnToPoolTask', () => { - it('should call deleteById method', () => { const scopeId = '1234'; diff --git a/src/app/core/tasks/claimed-task-data.service.ts b/src/app/core/tasks/claimed-task-data.service.ts index 76e5e769d7..5815dad6e5 100644 --- a/src/app/core/tasks/claimed-task-data.service.ts +++ b/src/app/core/tasks/claimed-task-data.service.ts @@ -1,25 +1,26 @@ -import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { Observable } from 'rxjs'; - -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { CoreState } from '../core.reducers'; -import { RequestService } from '../data/request.service'; -import { ClaimedTask } from './models/claimed-task-object.model'; -import { TasksService } from './tasks.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { dataService } from '../cache/builders/build-decorators'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { CoreState } from '../core.reducers'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { ClaimedTask } from './models/claimed-task-object.model'; +import { CLAIMED_TASK } from './models/claimed-task-object.resource-type'; import { ProcessTaskResponse } from './models/process-task-response'; +import { TasksService } from './tasks.service'; /** * The service handling all REST requests for ClaimedTask */ @Injectable() +@dataService(CLAIMED_TASK) export class ClaimedTaskDataService extends TasksService { protected responseMsToLive = 10 * 1000; @@ -34,7 +35,6 @@ export class ClaimedTaskDataService extends TasksService { * * @param {RequestService} requestService * @param {RemoteDataBuildService} rdbService - * @param {NormalizedObjectBuildService} dataBuildService * @param {Store} store * @param {ObjectCacheService} objectCache * @param {HALEndpointService} halService @@ -45,7 +45,6 @@ export class ClaimedTaskDataService extends TasksService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, @@ -56,35 +55,16 @@ export class ClaimedTaskDataService extends TasksService { } /** - * Make a request to approve the given task + * Make a request for the given task * * @param scopeId * The task id + * @param body + * The request body * @return {Observable} * Emit the server response */ - public approveTask(scopeId: string): Observable { - const body = { - submit_approve: 'true' - }; - return this.postToEndpoint(this.linkPath, this.requestService.uriEncodeBody(body), scopeId, this.makeHttpOptions()); - } - - /** - * Make a request to reject the given task - * - * @param reason - * The reason of reject - * @param scopeId - * The task id - * @return {Observable} - * Emit the server response - */ - public rejectTask(reason: string, scopeId: string): Observable { - const body = { - submit_reject: 'true', - reason - }; + public submitTask(scopeId: string, body: any): Observable { return this.postToEndpoint(this.linkPath, this.requestService.uriEncodeBody(body), scopeId, this.makeHttpOptions()); } 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 2f427f586f..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,9 +1,14 @@ +import { inheritSerialization } from 'cerialize'; +import { inheritLinkAnnotations, typedObject } from '../../cache/builders/build-decorators'; +import { CLAIMED_TASK } from './claimed-task-object.resource-type'; import { TaskObject } from './task-object.model'; -import { ResourceType } from '../../shared/resource-type'; /** * A model class for a ClaimedTask. */ +@typedObject +@inheritSerialization(TaskObject) +@inheritLinkAnnotations(TaskObject) export class ClaimedTask extends TaskObject { - static type = new ResourceType('claimedtask'); + static type = CLAIMED_TASK; } diff --git a/src/app/core/tasks/models/claimed-task-object.resource-type.ts b/src/app/core/tasks/models/claimed-task-object.resource-type.ts new file mode 100644 index 0000000000..9ad48fb229 --- /dev/null +++ b/src/app/core/tasks/models/claimed-task-object.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for ClaimedTask + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const CLAIMED_TASK = new ResourceType('claimedtask'); diff --git a/src/app/core/tasks/models/normalized-claimed-task-object.model.ts b/src/app/core/tasks/models/normalized-claimed-task-object.model.ts deleted file mode 100644 index d43a277f02..0000000000 --- a/src/app/core/tasks/models/normalized-claimed-task-object.model.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { NormalizedTaskObject } from './normalized-task-object.model'; -import { mapsTo, relationship } from '../../cache/builders/build-decorators'; -import { autoserialize, inheritSerialization } from 'cerialize'; -import { ClaimedTask } from './claimed-task-object.model'; -import { EPerson } from '../../eperson/models/eperson.model'; -import { Group } from '../../eperson/models/group.model'; -import { WorkflowItem } from '../../submission/models/workflowitem.model'; - -/** - * A normalized model class for a ClaimedTask. - */ -@mapsTo(ClaimedTask) -@inheritSerialization(NormalizedTaskObject) -export class NormalizedClaimedTask extends NormalizedTaskObject { - /** - * The task identifier - */ - @autoserialize - id: string; - - /** - * The workflow step - */ - @autoserialize - step: string; - - /** - * The task action type - */ - @autoserialize - action: string; - - /** - * The eperson object for this task - */ - @autoserialize - @relationship(EPerson, false) - eperson: string; - - /** - * The group object for this task - */ - @autoserialize - @relationship(Group, false) - group: string; - - /** - * The workflowitem object whom this task is related - */ - @autoserialize - @relationship(WorkflowItem, false) - workflowitem: string; - -} diff --git a/src/app/core/tasks/models/normalized-pool-task-object.model.ts b/src/app/core/tasks/models/normalized-pool-task-object.model.ts deleted file mode 100644 index bfc782f182..0000000000 --- a/src/app/core/tasks/models/normalized-pool-task-object.model.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { NormalizedTaskObject } from './normalized-task-object.model'; -import { PoolTask } from './pool-task-object.model'; -import { autoserialize, inheritSerialization } from 'cerialize'; -import { mapsTo, relationship } from '../../cache/builders/build-decorators'; -import { Group } from '../../eperson/models/group.model'; -import { WorkflowItem } from '../../submission/models/workflowitem.model'; - -/** - * A normalized model class for a PoolTask. - */ -@mapsTo(PoolTask) -@inheritSerialization(NormalizedTaskObject) -export class NormalizedPoolTask extends NormalizedTaskObject { - /** - * The task identifier - */ - @autoserialize - id: string; - - /** - * The workflow step - */ - @autoserialize - step: string; - - /** - * The task action type - */ - @autoserialize - action: string; - - /** - * The group object for this task - */ - @autoserialize - @relationship(Group, false) - group: string; - - /** - * The workflowitem object whom this task is related - */ - @autoserialize - @relationship(WorkflowItem, false) - workflowitem: string; -} diff --git a/src/app/core/tasks/models/normalized-task-object.model.ts b/src/app/core/tasks/models/normalized-task-object.model.ts deleted file mode 100644 index 2c96b95393..0000000000 --- a/src/app/core/tasks/models/normalized-task-object.model.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; -import { mapsTo, relationship } from '../../cache/builders/build-decorators'; -import { NormalizedDSpaceObject } from '../../cache/models/normalized-dspace-object.model'; -import { TaskObject } from './task-object.model'; -import { DSpaceObject } from '../../shared/dspace-object.model'; -import { Group } from '../../eperson/models/group.model'; -import { EPerson } from '../../eperson/models/eperson.model'; -import { WorkflowItem } from '../../submission/models/workflowitem.model'; - -/** - * An abstract normalized model class for a TaskObject. - */ -@mapsTo(TaskObject) -@inheritSerialization(NormalizedDSpaceObject) -export class NormalizedTaskObject extends NormalizedDSpaceObject { - - /** - * The task identifier - */ - @autoserialize - id: string; - - /** - * The workflow step - */ - @autoserialize - step: string; - - /** - * The task action type - */ - @autoserialize - action: string; - - /** - * The eperson object for this task - */ - @autoserialize - @relationship(EPerson, false) - eperson: string; - - /** - * The group object for this task - */ - @autoserialize - @relationship(Group, false) - group: string; - - /** - * The workflowitem object whom this task is related - */ - @autoserialize - @relationship(WorkflowItem, false) - workflowitem: string; -} 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 876b62373d..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,9 +1,14 @@ +import { inheritSerialization } from 'cerialize'; +import { inheritLinkAnnotations, typedObject } from '../../cache/builders/build-decorators'; +import { POOL_TASK } from './pool-task-object.resource-type'; import { TaskObject } from './task-object.model'; -import { ResourceType } from '../../shared/resource-type'; /** * A model class for a PoolTask. */ +@typedObject +@inheritSerialization(TaskObject) +@inheritLinkAnnotations(TaskObject) export class PoolTask extends TaskObject { - static type = new ResourceType('pooltask'); + static type = POOL_TASK; } diff --git a/src/app/core/tasks/models/pool-task-object.resource-type.ts b/src/app/core/tasks/models/pool-task-object.resource-type.ts new file mode 100644 index 0000000000..cab8ec1607 --- /dev/null +++ b/src/app/core/tasks/models/pool-task-object.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for PoolTask + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const POOL_TASK = new ResourceType('pooltask'); diff --git a/src/app/core/tasks/models/task-object.model.ts b/src/app/core/tasks/models/task-object.model.ts index 1f37548b04..86e0b46f36 100644 --- a/src/app/core/tasks/models/task-object.model.ts +++ b/src/app/core/tasks/models/task-object.model.ts @@ -1,46 +1,79 @@ +import { autoserialize, deserialize, inheritSerialization } from 'cerialize'; import { Observable } from 'rxjs'; +import { link, typedObject } from '../../cache/builders/build-decorators'; import { CacheableObject } from '../../cache/object-cache.reducer'; -import { DSpaceObject } from '../../shared/dspace-object.model'; import { RemoteData } from '../../data/remote-data'; -import { WorkflowItem } from '../../submission/models/workflowitem.model'; -import { ResourceType } from '../../shared/resource-type'; import { EPerson } from '../../eperson/models/eperson.model'; +import { EPERSON } from '../../eperson/models/eperson.resource-type'; import { Group } from '../../eperson/models/group.model'; +import { GROUP } from '../../eperson/models/group.resource-type'; +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'; +import { WORKFLOW_ACTION } from './workflow-action-object.resource-type'; +import { WorkflowAction } from './workflow-action-object.model'; /** * An abstract model class for a TaskObject. */ +@typedObject +@inheritSerialization(DSpaceObject) export class TaskObject extends DSpaceObject implements CacheableObject { - static type = new ResourceType('taskobject'); + static type = TASK_OBJECT; /** * The task identifier */ + @autoserialize id: string; /** * The workflow step */ + @autoserialize step: string; + /** + * The {@link HALLink}s for this TaskObject + */ + @deserialize + _links: { + self: HALLink; + owner: HALLink; + group: HALLink; + workflowitem: HALLink; + action: HALLink; + }; + + /** + * The EPerson for this task + * Will be undefined unless the eperson {@link HALLink} has been resolved. + */ + @link(EPERSON, false, 'owner') + eperson?: Observable>; + + /** + * The Group for this task + * Will be undefined unless the group {@link HALLink} has been resolved. + */ + @link(GROUP) + group?: Observable>; + + /** + * The WorkflowItem for this task + * Will be undefined unless the workflowitem {@link HALLink} has been resolved. + */ + @link(WORKFLOWITEM) + workflowitem?: Observable> | WorkflowItem; + /** * The task action type + * Will be undefined unless the group {@link HALLink} has been resolved. */ - action: string; + @link(WORKFLOW_ACTION, false, 'action') + action: Observable>; - /** - * The group of this task - */ - eperson: Observable>; - - /** - * The group of this task - */ - group: Observable>; - - /** - * The workflowitem object whom this task is related - */ - workflowitem: Observable> | WorkflowItem; } diff --git a/src/app/core/tasks/models/task-object.resource-type.ts b/src/app/core/tasks/models/task-object.resource-type.ts new file mode 100644 index 0000000000..d25e27ee94 --- /dev/null +++ b/src/app/core/tasks/models/task-object.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for TaskObject + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const TASK_OBJECT = new ResourceType('taskobject'); diff --git a/src/app/core/tasks/models/workflow-action-object.model.ts b/src/app/core/tasks/models/workflow-action-object.model.ts new file mode 100644 index 0000000000..720d817859 --- /dev/null +++ b/src/app/core/tasks/models/workflow-action-object.model.ts @@ -0,0 +1,25 @@ +import { inheritSerialization, autoserialize } from 'cerialize'; +import { typedObject } from '../../cache/builders/build-decorators'; +import { DSpaceObject } from '../../shared/dspace-object.model'; +import { WORKFLOW_ACTION } from './workflow-action-object.resource-type'; + +/** + * A model class for a WorkflowAction + */ +@typedObject +@inheritSerialization(DSpaceObject) +export class WorkflowAction extends DSpaceObject { + static type = WORKFLOW_ACTION; + + /** + * The workflow action's identifier + */ + @autoserialize + id: string; + + /** + * The options available for this workflow action + */ + @autoserialize + options: string[]; +} diff --git a/src/app/core/tasks/models/workflow-action-object.resource-type.ts b/src/app/core/tasks/models/workflow-action-object.resource-type.ts new file mode 100644 index 0000000000..d48ffd18f4 --- /dev/null +++ b/src/app/core/tasks/models/workflow-action-object.resource-type.ts @@ -0,0 +1,9 @@ +import { ResourceType } from '../../shared/resource-type'; + +/** + * The resource type for WorkflowAction + * + * Needs to be in a separate file to prevent circular + * dependencies in webpack. + */ +export const WORKFLOW_ACTION = new ResourceType('workflowaction'); diff --git a/src/app/core/tasks/pool-task-data.service.spec.ts b/src/app/core/tasks/pool-task-data.service.spec.ts index 7f40c6e89c..70ae4c7a91 100644 --- a/src/app/core/tasks/pool-task-data.service.spec.ts +++ b/src/app/core/tasks/pool-task-data.service.spec.ts @@ -6,7 +6,6 @@ import { getMockRequestService } from '../../shared/mocks/mock-request.service'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { CoreState } from '../core.reducers'; import { PoolTaskDataService } from './pool-task-data.service'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; @@ -22,9 +21,6 @@ describe('PoolTaskDataService', () => { const notificationsService = {} as NotificationsService; const http = {} as HttpClient; const comparator = {} as any; - const dataBuildService = { - normalize: (object) => object - } as NormalizedObjectBuildService; const objectCache = { addPatch: () => { /* empty */ @@ -39,7 +35,6 @@ describe('PoolTaskDataService', () => { return new PoolTaskDataService( requestService, rdbService, - dataBuildService, store, objectCache, halService, diff --git a/src/app/core/tasks/pool-task-data.service.ts b/src/app/core/tasks/pool-task-data.service.ts index 0e7704336d..f08274b5f1 100644 --- a/src/app/core/tasks/pool-task-data.service.ts +++ b/src/app/core/tasks/pool-task-data.service.ts @@ -1,25 +1,26 @@ -import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; - -import { Observable } from 'rxjs'; +import { Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; -import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { CoreState } from '../core.reducers'; -import { RequestService } from '../data/request.service'; -import { PoolTask } from './models/pool-task-object.model'; -import { TasksService } from './tasks.service'; -import { HALEndpointService } from '../shared/hal-endpoint.service'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; -import { ObjectCacheService } from '../cache/object-cache.service'; +import { Observable } from 'rxjs'; import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { dataService } from '../cache/builders/build-decorators'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { CoreState } from '../core.reducers'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; +import { RequestService } from '../data/request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { PoolTask } from './models/pool-task-object.model'; +import { POOL_TASK } from './models/pool-task-object.resource-type'; import { ProcessTaskResponse } from './models/process-task-response'; +import { TasksService } from './tasks.service'; /** * The service handling all REST requests for PoolTask */ @Injectable() +@dataService(POOL_TASK) export class PoolTaskDataService extends TasksService { /** @@ -34,7 +35,7 @@ export class PoolTaskDataService extends TasksService { * * @param {RequestService} requestService * @param {RemoteDataBuildService} rdbService - * @param {NormalizedObjectBuildService} dataBuildService + * @param {NormalizedObjectBuildService} linkService * @param {Store} store * @param {ObjectCacheService} objectCache * @param {HALEndpointService} halService @@ -45,7 +46,6 @@ export class PoolTaskDataService extends TasksService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, diff --git a/src/app/core/tasks/tasks.service.spec.ts b/src/app/core/tasks/tasks.service.spec.ts index 3ca9b8ea8f..782a950b2d 100644 --- a/src/app/core/tasks/tasks.service.spec.ts +++ b/src/app/core/tasks/tasks.service.spec.ts @@ -9,7 +9,6 @@ import { HALEndpointService } from '../shared/hal-endpoint.service'; import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service-stub'; import { TaskObject } from './models/task-object.model'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; -import { NormalizedObjectBuildService } from '../cache/builders/normalized-object-build.service'; import { Store } from '@ngrx/store'; import { CoreState } from '../core.reducers'; import { ObjectCacheService } from '../cache/object-cache.service'; @@ -18,7 +17,6 @@ import { HttpClient, HttpHeaders } from '@angular/common/http'; import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service'; import { ChangeAnalyzer } from '../data/change-analyzer'; import { compare, Operation } from 'fast-json-patch'; -import { NormalizedTaskObject } from './models/normalized-task-object.model'; import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service'; const LINK_NAME = 'test'; @@ -33,7 +31,6 @@ class TestService extends TasksService { constructor( protected requestService: RequestService, protected rdbService: RemoteDataBuildService, - protected dataBuildService: NormalizedObjectBuildService, protected store: Store, protected objectCache: ObjectCacheService, protected halService: HALEndpointService, @@ -44,11 +41,8 @@ class TestService extends TasksService { } } -class NormalizedTestTaskObject extends NormalizedTaskObject { -} - -class DummyChangeAnalyzer implements ChangeAnalyzer { - diff(object1: NormalizedTestTaskObject, object2: NormalizedTestTaskObject): Operation[] { +class DummyChangeAnalyzer implements ChangeAnalyzer { + diff(object1: TestTask, object2: TestTask): Operation[] { return compare((object1 as any).metadata, (object2 as any).metadata); } @@ -66,9 +60,6 @@ describe('TasksService', () => { const notificationsService = {} as NotificationsService; const http = {} as HttpClient; const comparator = new DummyChangeAnalyzer() as any; - const dataBuildService = { - normalize: (object) => object - } as NormalizedObjectBuildService; const objectCache = { addPatch: () => { /* empty */ @@ -83,7 +74,6 @@ describe('TasksService', () => { return new TestService( requestService, rdbService, - dataBuildService, store, objectCache, halService, diff --git a/src/app/core/tasks/tasks.service.ts b/src/app/core/tasks/tasks.service.ts index cf23bfd74b..0eae88e96c 100644 --- a/src/app/core/tasks/tasks.service.ts +++ b/src/app/core/tasks/tasks.service.ts @@ -18,10 +18,6 @@ import { CacheableObject } from '../cache/object-cache.reducer'; */ export abstract class TasksService extends DataService { - public getBrowseEndpoint(options: FindListOptions): Observable { - return this.halService.getEndpoint(this.linkPath); - } - /** * Fetch a RestRequest * 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 18ff77bf23..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 07e50eb6fb..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 394e5241e1..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/journal-entities/item-pages/journal-issue/journal-issue.component.html b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html index 87312f8784..cdfa6293c4 100644 --- a/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html +++ b/src/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html @@ -4,7 +4,7 @@
- +
- +
- + ; @@ -43,6 +57,11 @@ const mockItem: Item = Object.assign(new Item(), { }); describe('JournalComponent', () => { + const mockBitstreamDataService = { + getThumbnailFor(item: Item): Observable> { + return createSuccessfulRemoteDataObject$(new Bitstream()); + } + }; beforeEach(async(() => { TestBed.configureTestingModule({ imports: [TranslateModule.forRoot({ @@ -53,14 +72,25 @@ describe('JournalComponent', () => { })], declarations: [JournalComponent, GenericItemPageFieldComponent, TruncatePipe], providers: [ - {provide: ItemDataService, useValue: {}}, - {provide: TruncatableService, useValue: {}}, - {provide: RelationshipService, useValue: {}} + { provide: ItemDataService, useValue: {} }, + { provide: TruncatableService, useValue: {} }, + { provide: RelationshipService, useValue: {} }, + { provide: ObjectCacheService, useValue: {} }, + { provide: UUIDService, useValue: {} }, + { provide: Store, useValue: {} }, + { provide: RemoteDataBuildService, useValue: {} }, + { provide: CommunityDataService, useValue: {} }, + { provide: HALEndpointService, useValue: {} }, + { provide: HttpClient, useValue: {} }, + { provide: DSOChangeAnalyzer, useValue: {} }, + { provide: NotificationsService, useValue: {} }, + { provide: DefaultChangeAnalyzer, useValue: {} }, + { provide: BitstreamDataService, useValue: mockBitstreamDataService }, ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(JournalComponent, { - set: {changeDetection: ChangeDetectionStrategy.Default} + set: { changeDetection: ChangeDetectionStrategy.Default } }).compileComponents(); })); 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 5c42be2b24..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 b7eed7c8b4..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 f3a0dea81f..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/item-pages/org-unit/org-unit.component.html b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html index 1b23d567f5..784000b446 100644 --- a/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html +++ b/src/app/entity-groups/research-entities/item-pages/org-unit/org-unit.component.html @@ -4,7 +4,7 @@
- +
- +
- + diff --git a/src/app/entity-groups/research-entities/item-pages/project/project.component.spec.ts b/src/app/entity-groups/research-entities/item-pages/project/project.component.spec.ts index 6792000fd0..72857654ce 100644 --- a/src/app/entity-groups/research-entities/item-pages/project/project.component.spec.ts +++ b/src/app/entity-groups/research-entities/item-pages/project/project.component.spec.ts @@ -1,14 +1,12 @@ -import { Item } from '../../../../core/shared/item.model'; -import { RemoteData } from '../../../../core/data/remote-data'; -import { PaginatedList } from '../../../../core/data/paginated-list'; -import { PageInfo } from '../../../../core/shared/page-info.model'; -import { ProjectComponent } from './project.component'; -import { of as observableOf } from 'rxjs'; import { createRelationshipsObservable, getItemPageFieldsTest } from '../../../../+item-page/simple/item-types/shared/item.component.spec'; +import { PaginatedList } from '../../../../core/data/paginated-list'; +import { Item } from '../../../../core/shared/item.model'; +import { PageInfo } from '../../../../core/shared/page-info.model'; import { createSuccessfulRemoteDataObject$ } from '../../../../shared/testing/utils'; +import { ProjectComponent } from './project.component'; const mockItem: Item = Object.assign(new Item(), { bundles: createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])), diff --git a/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.html b/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.html index 0977413722..d8a4e744e4 100644 --- a/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.html +++ b/src/app/entity-groups/research-entities/metadata-representations/org-unit/org-unit-item-metadata-list-element.component.html @@ -9,5 +9,5 @@ + [tooltip]="metadataRepresentation.allMetadata(['organization.legalName']).length > 0 ? descTemplate : null"> 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 7d27b605ec..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 @@ -27,12 +27,12 @@ describe('OrgUnitItemMetadataListElementComponent', () => { }).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/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.html b/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.html index 55b8f38a5e..9d4a3566ad 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.html +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.html @@ -1,2 +1,4 @@ -
{{object.display}}
- +
+
{{object.display}}
+ +
diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts index c0512b4995..4612996e91 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/external-source-entry/external-source-entry-list-submission-element.component.ts @@ -3,7 +3,7 @@ import { ExternalSourceEntry } from '../../../../../core/shared/external-source- import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; import { ViewMode } from '../../../../../core/shared/view-mode.model'; import { Context } from '../../../../../core/shared/context.model'; -import { Component, OnInit } from '@angular/core'; +import { Component, Inject, OnInit } from '@angular/core'; import { Metadata } from '../../../../../core/shared/metadata.utils'; import { MetadataValue } from '../../../../../core/shared/metadata.models'; diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.html b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.html index b0fa714371..93165c24cd 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.html +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.html @@ -1,6 +1,6 @@
- +
; @@ -79,6 +91,11 @@ function init() { describe('OrgUnitSearchResultListSubmissionElementComponent', () => { beforeEach(async(() => { init(); + const mockBitstreamDataService = { + getThumbnailFor(item: Item): Observable> { + return createSuccessfulRemoteDataObject$(new Bitstream()); + } + }; TestBed.configureTestingModule({ declarations: [OrgUnitSearchResultListSubmissionElementComponent, TruncatePipe], providers: [ @@ -89,7 +106,16 @@ describe('OrgUnitSearchResultListSubmissionElementComponent', () => { { provide: NgbModal, useValue: {} }, { provide: ItemDataService, useValue: {} }, { provide: SelectableListService, useValue: {} }, - { provide: Store, useValue: {} } + { provide: Store, useValue: {} }, + { provide: ObjectCacheService, useValue: {} }, + { provide: UUIDService, useValue: {} }, + { provide: RemoteDataBuildService, useValue: {} }, + { provide: CommunityDataService, useValue: {} }, + { provide: HALEndpointService, useValue: {} }, + { provide: HttpClient, useValue: {} }, + { provide: DSOChangeAnalyzer, useValue: {} }, + { provide: DefaultChangeAnalyzer, useValue: {} }, + { provide: BitstreamDataService, useValue: mockBitstreamDataService }, ], schemas: [NO_ERRORS_SCHEMA] diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.ts index cbddb8d6f9..96f28a799b 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.ts +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/org-unit/org-unit-search-result-list-submission-element.component.ts @@ -1,4 +1,8 @@ import { Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service'; +import { Bitstream } from '../../../../../core/shared/bitstream.model'; +import { getFirstSucceededRemoteDataPayload } from '../../../../../core/shared/operators'; import { SearchResultListElementComponent } from '../../../../../shared/object-list/search-result-list-element/search-result-list-element.component'; import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; @@ -37,6 +41,7 @@ export class OrgUnitSearchResultListSubmissionElementComponent extends SearchRes private translateService: TranslateService, private modalService: NgbModal, private itemDataService: ItemDataService, + private bitstreamDataService: BitstreamDataService, private selectableListService: SelectableListService) { super(truncatableService); } @@ -95,4 +100,11 @@ export class OrgUnitSearchResultListSubmissionElementComponent extends SearchRes modalComp.value = value; return modalRef.result; } + + // TODO refactor to return RemoteData, and thumbnail template to deal with loading + getThumbnail(): Observable { + return this.bitstreamDataService.getThumbnailFor(this.dso).pipe( + getFirstSucceededRemoteDataPayload() + ); + } } diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.html b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.html index df93c2f4f3..25c091d386 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.html +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.html @@ -1,6 +1,6 @@
- +
diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.spec.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.spec.ts index a21f0ec075..0949ebea7e 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.spec.ts +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.spec.ts @@ -1,21 +1,33 @@ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpClient } from '@angular/common/http'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { of as observableOf } from 'rxjs'; -import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; -import { PersonSearchResultListSubmissionElementComponent } from './person-search-result-list-submission-element.component'; -import { Item } from '../../../../../core/shared/item.model'; -import { TruncatePipe } from '../../../../../shared/utils/truncate.pipe'; -import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service'; -import { RelationshipService } from '../../../../../core/data/relationship.service'; -import { NotificationsService } from '../../../../../shared/notifications/notifications.service'; -import { TranslateService } from '@ngx-translate/core'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { ItemDataService } from '../../../../../core/data/item-data.service'; -import { SelectableListService } from '../../../../../shared/object-list/selectable-list/selectable-list.service'; import { Store } from '@ngrx/store'; -import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/testing/utils'; +import { TranslateService } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; +import { Observable } from 'rxjs/internal/Observable'; +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'; +import { ItemDataService } from '../../../../../core/data/item-data.service'; import { PaginatedList } from '../../../../../core/data/paginated-list'; +import { RelationshipService } from '../../../../../core/data/relationship.service'; +import { RemoteData } from '../../../../../core/data/remote-data'; +import { Bitstream } from '../../../../../core/shared/bitstream.model'; +import { HALEndpointService } from '../../../../../core/shared/hal-endpoint.service'; +import { Item } from '../../../../../core/shared/item.model'; +import { UUIDService } from '../../../../../core/shared/uuid.service'; +import { NotificationsService } from '../../../../../shared/notifications/notifications.service'; +import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; +import { SelectableListService } from '../../../../../shared/object-list/selectable-list/selectable-list.service'; +import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/testing/utils'; +import { TruncatableService } from '../../../../../shared/truncatable/truncatable.service'; +import { TruncatePipe } from '../../../../../shared/utils/truncate.pipe'; +import { PersonSearchResultListSubmissionElementComponent } from './person-search-result-list-submission-element.component'; let personListElementComponent: PersonSearchResultListSubmissionElementComponent; let fixture: ComponentFixture; @@ -71,6 +83,11 @@ function init() { } describe('PersonSearchResultListElementSubmissionComponent', () => { + const mockBitstreamDataService = { + getThumbnailFor(item: Item): Observable> { + return createSuccessfulRemoteDataObject$(new Bitstream()); + } + }; beforeEach(async(() => { init(); TestBed.configureTestingModule({ @@ -83,7 +100,16 @@ describe('PersonSearchResultListElementSubmissionComponent', () => { { provide: NgbModal, useValue: {} }, { provide: ItemDataService, useValue: {} }, { provide: SelectableListService, useValue: {} }, - { provide: Store, useValue: {}} + { provide: Store, useValue: {}}, + { provide: ObjectCacheService, useValue: {} }, + { provide: UUIDService, useValue: {} }, + { provide: RemoteDataBuildService, useValue: {} }, + { provide: CommunityDataService, useValue: {} }, + { provide: HALEndpointService, useValue: {} }, + { provide: HttpClient, useValue: {} }, + { provide: DSOChangeAnalyzer, useValue: {} }, + { provide: DefaultChangeAnalyzer, useValue: {} }, + { provide: BitstreamDataService, useValue: mockBitstreamDataService }, ], schemas: [NO_ERRORS_SCHEMA] diff --git a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.ts b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.ts index 37fd77649b..83761c6c20 100644 --- a/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.ts +++ b/src/app/entity-groups/research-entities/submission/item-list-elements/person/person-search-result-list-submission-element.component.ts @@ -1,4 +1,8 @@ import { Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service'; +import { Bitstream } from '../../../../../core/shared/bitstream.model'; +import { getFirstSucceededRemoteDataPayload } from '../../../../../core/shared/operators'; import { SearchResultListElementComponent } from '../../../../../shared/object-list/search-result-list-element/search-result-list-element.component'; import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model'; import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator'; @@ -37,6 +41,7 @@ export class PersonSearchResultListSubmissionElementComponent extends SearchResu private translateService: TranslateService, private modalService: NgbModal, private itemDataService: ItemDataService, + private bitstreamDataService: BitstreamDataService, private selectableListService: SelectableListService) { super(truncatableService); } @@ -95,4 +100,11 @@ export class PersonSearchResultListSubmissionElementComponent extends SearchResu modalComp.value = value; return modalRef.result; } + + // TODO refactor to return RemoteData, and thumbnail template to deal with loading + getThumbnail(): Observable { + return this.bitstreamDataService.getThumbnailFor(this.dso).pipe( + getFirstSucceededRemoteDataPayload() + ); + } } diff --git a/src/app/entity-groups/research-entities/submission/name-variant-modal/name-variant-modal.component.spec.ts b/src/app/entity-groups/research-entities/submission/name-variant-modal/name-variant-modal.component.spec.ts index b5043ea2d6..e3c73617f1 100644 --- a/src/app/entity-groups/research-entities/submission/name-variant-modal/name-variant-modal.component.spec.ts +++ b/src/app/entity-groups/research-entities/submission/name-variant-modal/name-variant-modal.component.spec.ts @@ -18,7 +18,7 @@ describe('NameVariantModalComponent', () => { init(); TestBed.configureTestingModule({ declarations: [NameVariantModalComponent], - imports: [NgbModule.forRoot(), TranslateModule.forRoot()], + imports: [NgbModule, TranslateModule.forRoot()], providers: [{ provide: NgbActiveModal, useValue: modal }] }) .compileComponents(); 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/search-navbar/search-navbar.component.ts b/src/app/search-navbar/search-navbar.component.ts index 1bedfb73ef..01329c1cbe 100644 --- a/src/app/search-navbar/search-navbar.component.ts +++ b/src/app/search-navbar/search-navbar.component.ts @@ -22,7 +22,7 @@ export class SearchNavbarComponent { isExpanded = 'collapsed'; // Search input field - @ViewChild('searchInput') searchField: ElementRef; + @ViewChild('searchInput', {static: false}) searchField: ElementRef; constructor(private formBuilder: FormBuilder, private router: Router, private searchService: SearchService) { this.searchForm = this.formBuilder.group(({ 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/browse-by/browse-by.component.spec.ts b/src/app/shared/browse-by/browse-by.component.spec.ts index 5592b88c86..51db888c4b 100644 --- a/src/app/shared/browse-by/browse-by.component.spec.ts +++ b/src/app/shared/browse-by/browse-by.component.spec.ts @@ -51,7 +51,7 @@ describe('BrowseByComponent', () => { CommonModule, TranslateModule.forRoot(), SharedModule, - NgbModule.forRoot(), + NgbModule, StoreModule.forRoot({}), TranslateModule.forRoot({ loader: { diff --git a/src/app/shared/chips/chips.component.spec.ts b/src/app/shared/chips/chips.component.spec.ts index 6dbecf5165..facfc8061b 100644 --- a/src/app/shared/chips/chips.component.spec.ts +++ b/src/app/shared/chips/chips.component.spec.ts @@ -6,16 +6,16 @@ import { Chips } from './models/chips.model'; import { UploaderService } from '../uploader/uploader.service'; import { ChipsComponent } from './chips.component'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; -import { SortablejsModule } from 'angular-sortablejs'; import { By } from '@angular/platform-browser'; import { FormFieldMetadataValueObject } from '../form/builder/models/form-field-metadata-value.model'; -import { createTestComponent, hasClass } from '../testing/utils'; +import { createTestComponent } from '../testing/utils'; import { AuthorityConfidenceStateDirective } from '../authority-confidence/authority-confidence-state.directive'; import { TranslateModule } from '@ngx-translate/core'; import { GlobalConfig } from '../../../config/global-config.interface'; import { GLOBAL_CONFIG } from '../../../config'; import { MOCK_SUBMISSION_CONFIG } from '../testing/mock-submission-config'; import { ConfidenceType } from '../../core/integration/models/confidence-type'; +import { SortablejsModule } from 'ngx-sortablejs'; describe('ChipsComponent test suite', () => { @@ -32,7 +32,7 @@ describe('ChipsComponent test suite', () => { TestBed.configureTestingModule({ imports: [ - NgbModule.forRoot(), + NgbModule, SortablejsModule.forRoot({animation: 150}), TranslateModule.forRoot() ], diff --git a/src/app/shared/chips/chips.component.ts b/src/app/shared/chips/chips.component.ts index 1283decc9f..82bb8f0f72 100644 --- a/src/app/shared/chips/chips.component.ts +++ b/src/app/shared/chips/chips.component.ts @@ -1,13 +1,13 @@ import { ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges, } from '@angular/core'; import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; -import { SortablejsOptions } from 'angular-sortablejs'; import { isObject } from 'lodash'; import { Chips } from './models/chips.model'; import { ChipsItem } from './models/chips-item.model'; import { UploaderService } from '../uploader/uploader.service'; import { TranslateService } from '@ngx-translate/core'; +import { Options } from 'sortablejs'; @Component({ selector: 'ds-chips', @@ -25,7 +25,7 @@ export class ChipsComponent implements OnChanges { @Output() remove: EventEmitter = new EventEmitter(); @Output() change: EventEmitter = new EventEmitter(); - options: SortablejsOptions; + options: Options; dragged = -1; tipText: string[]; diff --git a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts index 58488f721a..454a036b15 100644 --- a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts +++ b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.spec.ts @@ -1,27 +1,27 @@ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; import { Location } from '@angular/common'; -import { RouterTestingModule } from '@angular/router/testing'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { DynamicFormControlModel, DynamicFormService, DynamicInputModel } from '@ng-dynamic-forms/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { FormControl, FormGroup } from '@angular/forms'; -import { Community } from '../../../core/shared/community.model'; -import { ComColFormComponent } from './comcol-form.component'; -import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { hasValue } from '../../empty.util'; -import { VarDirective } from '../../utils/var.directive'; -import { NotificationsService } from '../../notifications/notifications.service'; -import { NotificationsServiceStub } from '../../testing/notifications-service-stub'; -import { AuthService } from '../../../core/auth/auth.service'; -import { AuthServiceMock } from '../../mocks/mock-auth.service'; +import { By } from '@angular/platform-browser'; +import { RouterTestingModule } from '@angular/router/testing'; +import { DynamicFormControlModel, DynamicFormService, DynamicInputModel } from '@ng-dynamic-forms/core'; +import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; -import { RemoteData } from '../../../core/data/remote-data'; -import { RestRequestMethod } from '../../../core/data/rest-request-method'; +import { AuthService } from '../../../core/auth/auth.service'; +import { ObjectCacheService } from '../../../core/cache/object-cache.service'; import { ErrorResponse, RestResponse } from '../../../core/cache/response.models'; +import { RemoteData } from '../../../core/data/remote-data'; import { RequestError } from '../../../core/data/request.models'; import { RequestService } from '../../../core/data/request.service'; -import { ObjectCacheService } from '../../../core/cache/object-cache.service'; -import { By } from '@angular/platform-browser'; +import { RestRequestMethod } from '../../../core/data/rest-request-method'; +import { Community } from '../../../core/shared/community.model'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { hasValue } from '../../empty.util'; +import { AuthServiceMock } from '../../mocks/mock-auth.service'; +import { NotificationsService } from '../../notifications/notifications.service'; +import { NotificationsServiceStub } from '../../testing/notifications-service-stub'; +import { VarDirective } from '../../utils/var.directive'; +import { ComColFormComponent } from './comcol-form.component'; describe('ComColFormComponent', () => { let comp: ComColFormComponent; @@ -43,10 +43,10 @@ describe('ComColFormComponent', () => { const dcRandom = 'dc.random'; const dcAbstract = 'dc.description.abstract'; - const titleMD = { [dcTitle]: [ { value: 'Community Title', language: null } ] }; - const randomMD = { [dcRandom]: [ { value: 'Random metadata excluded from form', language: null } ] }; - const abstractMD = { [dcAbstract]: [ { value: 'Community description', language: null } ] }; - const newTitleMD = { [dcTitle]: [ { value: 'New Community Title', language: null } ] }; + const titleMD = { [dcTitle]: [{ value: 'Community Title', language: null }] }; + const randomMD = { [dcRandom]: [{ value: 'Random metadata excluded from form', language: null }] }; + const abstractMD = { [dcAbstract]: [{ value: 'Community description', language: null }] }; + const newTitleMD = { [dcTitle]: [{ value: 'New Community Title', language: null }] }; const formModel = [ new DynamicInputModel({ id: 'title', @@ -96,7 +96,9 @@ describe('ComColFormComponent', () => { describe('when the dso doesn\'t contain an ID (newly created)', () => { beforeEach(() => { - initComponent(new Community()); + initComponent(Object.assign(new Community(), { + _links: { self: { href: 'community-self' } } + })); }); it('should initialize the uploadFilesOptions with a placeholder url', () => { @@ -119,7 +121,6 @@ describe('ComColFormComponent', () => { } } ); - comp.onSubmit(); expect(comp.submitForm.emit).toHaveBeenCalledWith( @@ -136,7 +137,7 @@ describe('ComColFormComponent', () => { type: Community.type }, ), - uploader: {} as any, + uploader: undefined, deleteLogo: false } ); @@ -191,7 +192,8 @@ describe('ComColFormComponent', () => { beforeEach(() => { initComponent(Object.assign(new Community(), { id: 'community-id', - logo: observableOf(new RemoteData(false, false, true, null, undefined)) + logo: observableOf(new RemoteData(false, false, true, null, undefined)), + _links: { self: { href: 'community-self' } } })); }); @@ -208,7 +210,8 @@ describe('ComColFormComponent', () => { beforeEach(() => { initComponent(Object.assign(new Community(), { id: 'community-id', - logo: observableOf(new RemoteData(false, false, true, null, {})) + logo: observableOf(new RemoteData(false, false, true, null, {})), + _links: { self: { href: 'community-self' } } })); }); @@ -239,7 +242,7 @@ describe('ComColFormComponent', () => { }); describe('when dsoService.deleteLogo returns an error response', () => { - const response = new ErrorResponse(new RequestError('errorMessage')); + const response = new ErrorResponse(new RequestError('this error was purposely thrown, to test error notifications')); beforeEach(() => { spyOn(dsoService, 'deleteLogo').and.returnValue(observableOf(response)); @@ -313,9 +316,9 @@ describe('ComColFormComponent', () => { comp.formModel = []; comp.dso = dso; (comp as any).type = Community.type; - comp.uploaderComponent = Object.assign({ - uploader: {} - }); + comp.uploaderComponent = {uploader: {}} as any; + + console.log(comp); (comp as any).dsoService = dsoService; fixture.detectChanges(); location = (comp as any).location; diff --git a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts index 435ef61d72..f8199d2aad 100644 --- a/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts +++ b/src/app/shared/comcol-forms/comcol-form/comcol-form.component.ts @@ -1,34 +1,30 @@ -import { Component, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'; import { Location } from '@angular/common'; -import { - DynamicFormControlModel, - DynamicFormService, - DynamicInputModel -} from '@ng-dynamic-forms/core'; +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'; import { FormGroup } from '@angular/forms'; +import { DynamicFormControlModel, DynamicFormService, DynamicInputModel } from '@ng-dynamic-forms/core'; import { TranslateService } from '@ngx-translate/core'; +import { FileUploader } from 'ng2-file-upload'; +import { combineLatest as observableCombineLatest } from 'rxjs'; +import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { AuthService } from '../../../core/auth/auth.service'; +import { ObjectCacheService } from '../../../core/cache/object-cache.service'; +import { ErrorResponse, RestResponse } from '../../../core/cache/response.models'; +import { ComColDataService } from '../../../core/data/comcol-data.service'; +import { RemoteData } from '../../../core/data/remote-data'; +import { RequestService } from '../../../core/data/request.service'; +import { RestRequestMethod } from '../../../core/data/rest-request-method'; +import { Bitstream } from '../../../core/shared/bitstream.model'; +import { Collection } from '../../../core/shared/collection.model'; +import { Community } from '../../../core/shared/community.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; import { MetadataMap, MetadataValue } from '../../../core/shared/metadata.models'; import { ResourceType } from '../../../core/shared/resource-type'; import { hasValue, isNotEmpty } from '../../empty.util'; -import { UploaderOptions } from '../../uploader/uploader-options.model'; import { NotificationsService } from '../../notifications/notifications.service'; -import { ComColDataService } from '../../../core/data/comcol-data.service'; -import { Subscription } from 'rxjs/internal/Subscription'; -import { AuthService } from '../../../core/auth/auth.service'; -import { Community } from '../../../core/shared/community.model'; -import { Collection } from '../../../core/shared/collection.model'; +import { UploaderOptions } from '../../uploader/uploader-options.model'; import { UploaderComponent } from '../../uploader/uploader.component'; -import { FileUploader } from 'ng2-file-upload'; -import { ErrorResponse, RestResponse } from '../../../core/cache/response.models'; -import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; -import { RemoteData } from '../../../core/data/remote-data'; -import { Bitstream } from '../../../core/shared/bitstream.model'; -import { combineLatest as observableCombineLatest } from 'rxjs'; -import { RestRequestMethod } from '../../../core/data/rest-request-method'; -import { RequestService } from '../../../core/data/request.service'; -import { ObjectCacheService } from '../../../core/cache/object-cache.service'; /** * A form for creating and editing Communities or Collections @@ -43,7 +39,7 @@ export class ComColFormComponent implements OnInit, OnDe /** * The logo uploader component */ - @ViewChild(UploaderComponent) uploaderComponent: UploaderComponent; + @ViewChild(UploaderComponent, {static: false}) uploaderComponent: UploaderComponent; /** * DSpaceObject that the form represents @@ -253,8 +249,8 @@ export class ComColFormComponent implements OnInit, OnDe * Refresh the object's cache to ensure the latest version */ private refreshCache() { - this.requestService.removeByHrefSubstring(this.dso.self); - this.objectCache.remove(this.dso.self); + this.requestService.removeByHrefSubstring(this.dso._links.self.href); + this.objectCache.remove(this.dso._links.self.href); } /** diff --git a/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts b/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts index 7b23c59498..e9373aff47 100644 --- a/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts +++ b/src/app/shared/comcol-forms/create-comcol-page/create-comcol-page.component.ts @@ -1,19 +1,18 @@ import { Component, OnInit } from '@angular/core'; -import { Community } from '../../../core/shared/community.model'; -import { CommunityDataService } from '../../../core/data/community-data.service'; -import { Observable } from 'rxjs'; -import { RouteService } from '../../../core/services/route.service'; -import { ActivatedRoute, Router } from '@angular/router'; -import { RemoteData } from '../../../core/data/remote-data'; -import { hasValue, isNotEmpty, isNotUndefined } from '../../empty.util'; -import { take } from 'rxjs/operators'; -import { getSucceededRemoteData } from '../../../core/shared/operators'; -import { DSpaceObject } from '../../../core/shared/dspace-object.model'; -import { DataService } from '../../../core/data/data.service'; -import { ComColDataService } from '../../../core/data/comcol-data.service'; -import { NotificationsService } from '../../notifications/notifications.service'; +import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { ComColDataService } from '../../../core/data/comcol-data.service'; +import { CommunityDataService } from '../../../core/data/community-data.service'; +import { RemoteData } from '../../../core/data/remote-data'; +import { RouteService } from '../../../core/services/route.service'; +import { Community } from '../../../core/shared/community.model'; +import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { getSucceededRemoteData } from '../../../core/shared/operators'; import { ResourceType } from '../../../core/shared/resource-type'; +import { hasValue, isNotEmpty, isNotUndefined } from '../../empty.util'; +import { NotificationsService } from '../../notifications/notifications.service'; /** * Component representing the create page for communities and collections 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-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component.spec.ts b/src/app/shared/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component.spec.ts index 5711aa4e70..84454c4250 100644 --- a/src/app/shared/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component.spec.ts +++ b/src/app/shared/comcol-forms/edit-comcol-page/comcol-metadata/comcol-metadata.component.spec.ts @@ -1,21 +1,20 @@ -import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { CommunityDataService } from '../../../../core/data/community-data.service'; -import { ActivatedRoute, Router } from '@angular/router'; -import { Community } from '../../../../core/shared/community.model'; -import { of as observableOf } from 'rxjs/internal/observable/of'; -import { RemoteData } from '../../../../core/data/remote-data'; -import { TranslateModule } from '@ngx-translate/core'; -import { SharedModule } from '../../../shared.module'; import { CommonModule } from '@angular/common'; -import { RouterTestingModule } from '@angular/router/testing'; -import { DataService } from '../../../../core/data/data.service'; import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { ComcolMetadataComponent } from './comcol-metadata.component'; -import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../testing/utils'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs/internal/observable/of'; import { ComColDataService } from '../../../../core/data/comcol-data.service'; -import { NotificationsServiceStub } from '../../../testing/notifications-service-stub'; +import { CommunityDataService } from '../../../../core/data/community-data.service'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { Community } from '../../../../core/shared/community.model'; +import { DSpaceObject } from '../../../../core/shared/dspace-object.model'; import { NotificationsService } from '../../../notifications/notifications.service'; +import { SharedModule } from '../../../shared.module'; +import { NotificationsServiceStub } from '../../../testing/notifications-service-stub'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../../testing/utils'; +import { ComcolMetadataComponent } from './comcol-metadata.component'; describe('ComColMetadataComponent', () => { let comp: ComcolMetadataComponent; diff --git a/src/app/shared/comcol-forms/edit-comcol-page/comcol-role/comcol-role.component.html b/src/app/shared/comcol-forms/edit-comcol-page/comcol-role/comcol-role.component.html new file mode 100644 index 0000000000..1e89e3facf --- /dev/null +++ b/src/app/shared/comcol-forms/edit-comcol-page/comcol-role/comcol-role.component.html @@ -0,0 +1,52 @@ +
+ +
+ +
+ +
+ {{'comcol-role.edit.' + comcolRole.name + '.name' | translate}} +
+ +
+ +
+ {{'comcol-role.edit.no-group' | translate}} +
+
+ {{'comcol-role.edit.' + comcolRole.name + '.anonymous-group' | translate}} +
+ + {{group.name}} + +
+ +
+
+ {{'comcol-role.edit.create' | translate}} +
+
+ {{'comcol-role.edit.restrict' | translate}} +
+
+ {{'comcol-role.edit.delete' | translate}} +
+
+ +
+ +
+ {{'comcol-role.edit.' + comcolRole.name + '.description' | translate}} +
+ +
+ +
diff --git a/src/app/shared/comcol-forms/edit-comcol-page/comcol-role/comcol-role.component.scss b/src/app/shared/comcol-forms/edit-comcol-page/comcol-role/comcol-role.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/comcol-forms/edit-comcol-page/comcol-role/comcol-role.component.spec.ts b/src/app/shared/comcol-forms/edit-comcol-page/comcol-role/comcol-role.component.spec.ts new file mode 100644 index 0000000000..4694c13603 --- /dev/null +++ b/src/app/shared/comcol-forms/edit-comcol-page/comcol-role/comcol-role.component.spec.ts @@ -0,0 +1,176 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComcolRoleComponent } from './comcol-role.component'; +import { GroupDataService } from '../../../../core/eperson/group-data.service'; +import { By } from '@angular/platform-browser'; +import { SharedModule } from '../../../shared.module'; +import { TranslateModule } from '@ngx-translate/core'; +import { ChangeDetectorRef, DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { RequestService } from '../../../../core/data/request.service'; +import { ComcolRole } from './comcol-role'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { RouterTestingModule } from '@angular/router/testing'; +import { Collection } from '../../../../core/shared/collection.model'; + +describe('ComcolRoleComponent', () => { + + let fixture: ComponentFixture; + let comp: ComcolRoleComponent; + let de: DebugElement; + + let requestService; + let groupService; + + let group; + let statusCode; + + beforeEach(() => { + + requestService = {hasByHrefObservable: () => observableOf(true)}; + + groupService = { + findByHref: () => undefined, + createComcolGroup: jasmine.createSpy('createComcolGroup'), + deleteComcolGroup: jasmine.createSpy('deleteComcolGroup'), + }; + + spyOn(groupService, 'findByHref').and.callFake((link) => { + if (link === 'test role link') { + return observableOf(new RemoteData( + false, + false, + true, + undefined, + group, + statusCode, + )); + } + }); + + TestBed.configureTestingModule({ + imports: [ + SharedModule, + RouterTestingModule.withRoutes([]), + TranslateModule.forRoot(), + ], + providers: [ + { provide: GroupDataService, useValue: groupService }, + { provide: RequestService, useValue: requestService }, + ], schemas: [ + NO_ERRORS_SCHEMA + ] + }).compileComponents(); + + fixture = TestBed.createComponent(ComcolRoleComponent); + comp = fixture.componentInstance; + de = fixture.debugElement; + + comp.comcolRole = new ComcolRole( + 'test role name', + 'test role endpoint', + ); + + comp.dso = Object.assign( + new Collection(), { + _links: { + 'test role endpoint': { + href: 'test role link', + } + } + } + ); + + fixture.detectChanges(); + }); + + describe('when there is no group yet', () => { + + beforeEach(() => { + group = null; + statusCode = 204; + fixture.detectChanges(); + }); + + it('should have a create button but no restrict or delete button', () => { + expect(de.query(By.css('.btn.create'))) + .toBeTruthy(); + expect(de.query(By.css('.btn.restrict'))) + .toBeNull(); + expect(de.query(By.css('.btn.delete'))) + .toBeNull(); + }); + + describe('when the create button is pressed', () => { + + beforeEach(() => { + de.query(By.css('.btn.create')).nativeElement.click(); + }); + + it('should call the groupService create method', () => { + expect(groupService.createComcolGroup).toHaveBeenCalled(); + }); + }); + }); + + describe('when the related group is the Anonymous group', () => { + + beforeEach(() => { + group = { + name: 'Anonymous' + }; + statusCode = 200; + fixture.detectChanges(); + }); + + it('should have a restrict button but no create or delete button', () => { + expect(de.query(By.css('.btn.create'))) + .toBeNull(); + expect(de.query(By.css('.btn.restrict'))) + .toBeTruthy(); + expect(de.query(By.css('.btn.delete'))) + .toBeNull(); + }); + + describe('when the restrict button is pressed', () => { + + beforeEach(() => { + de.query(By.css('.btn.restrict')).nativeElement.click(); + }); + + it('should call the groupService create method', () => { + expect(groupService.createComcolGroup).toHaveBeenCalledWith(comp.dso, 'test role link'); + }); + }); + }); + + describe('when the related group is a custom group', () => { + + beforeEach(() => { + group = { + name: 'custom group name' + }; + statusCode = 200; + fixture.detectChanges(); + }); + + it('should have a delete button but no create or restrict button', () => { + expect(de.query(By.css('.btn.create'))) + .toBeNull(); + expect(de.query(By.css('.btn.restrict'))) + .toBeNull(); + expect(de.query(By.css('.btn.delete'))) + .toBeTruthy(); + }); + + describe('when the delete button is pressed', () => { + + beforeEach(() => { + de.query(By.css('.btn.delete')).nativeElement.click(); + }); + + it('should call the groupService delete method', () => { + expect(groupService.deleteComcolGroup).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/app/shared/comcol-forms/edit-comcol-page/comcol-role/comcol-role.component.ts b/src/app/shared/comcol-forms/edit-comcol-page/comcol-role/comcol-role.component.ts new file mode 100644 index 0000000000..41cb7e7cd2 --- /dev/null +++ b/src/app/shared/comcol-forms/edit-comcol-page/comcol-role/comcol-role.component.ts @@ -0,0 +1,126 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { Group } from '../../../../core/eperson/models/group.model'; +import { Community } from '../../../../core/shared/community.model'; +import { Observable } from 'rxjs'; +import { getGroupEditPath } from '../../../../+admin/admin-access-control/admin-access-control-routing.module'; +import { GroupDataService } from '../../../../core/eperson/group-data.service'; +import { Collection } from '../../../../core/shared/collection.model'; +import { filter, map } from 'rxjs/operators'; +import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../core/shared/operators'; +import { ComcolRole } from './comcol-role'; +import { RequestService } from '../../../../core/data/request.service'; +import { RemoteData } from '../../../../core/data/remote-data'; + +/** + * Component for managing a community or collection role. + */ +@Component({ + selector: 'ds-comcol-role', + styleUrls: ['./comcol-role.component.scss'], + templateUrl: './comcol-role.component.html' +}) +export class ComcolRoleComponent implements OnInit { + + /** + * The community or collection to manage. + */ + @Input() + dso: Community|Collection; + + /** + * The role to manage + */ + @Input() + comcolRole: ComcolRole; + + constructor( + protected requestService: RequestService, + protected groupService: GroupDataService, + ) { + } + + /** + * The link to the related group. + */ + get groupLink(): string { + return this.dso._links[this.comcolRole.linkName].href; + } + + /** + * The group for this role, as an observable remote data. + */ + get groupRD$(): Observable> { + return this.groupService.findByHref(this.groupLink).pipe( + filter((groupRD) => !!groupRD.statusCode), + ); + } + + /** + * The group for this role, as an observable. + */ + get group$(): Observable { + return this.groupRD$.pipe( + getSucceededRemoteData(), + filter((groupRD) => groupRD != null), + getRemoteDataPayload(), + ); + } + + /** + * The link to the group edit page as an observable. + */ + get editGroupLink$(): Observable { + return this.group$.pipe( + map((group) => getGroupEditPath(group.id)), + ); + } + + /** + * Return true if there is no group for this ComcolRole, as an observable. + */ + hasNoGroup$(): Observable { + return this.groupRD$.pipe( + map((groupRD) => groupRD.statusCode === 204), + ) + } + + /** + * Return true if the group for this ComcolRole is the Anonymous group, as an observable. + */ + hasAnonymousGroup$(): Observable { + return this.group$.pipe( + map((group) => group.name === 'Anonymous'), + ) + } + + /** + * Return true if there is a group for this ComcolRole other than the Anonymous group, as an observable. + */ + hasCustomGroup$(): Observable { + return this.hasAnonymousGroup$().pipe( + map((anonymous) => !anonymous), + ) + } + + /** + * Create a group for this community or collection role. + */ + create() { + this.groupService.createComcolGroup(this.dso, this.groupLink).subscribe(); + } + + /** + * Delete the group for this community or collection role. + */ + delete() { + this.groupService.deleteComcolGroup(this.groupLink).subscribe(); + } + + ngOnInit(): void { + this.requestService.hasByHrefObservable(this.groupLink) + .pipe( + filter((hasByHrefObservable) => !hasByHrefObservable), + ) + .subscribe(() => this.groupRD$.subscribe()); + } +} diff --git a/src/app/shared/comcol-forms/edit-comcol-page/comcol-role/comcol-role.ts b/src/app/shared/comcol-forms/edit-comcol-page/comcol-role/comcol-role.ts new file mode 100644 index 0000000000..2ac74fe67b --- /dev/null +++ b/src/app/shared/comcol-forms/edit-comcol-page/comcol-role/comcol-role.ts @@ -0,0 +1,77 @@ +import { Community } from '../../../../core/shared/community.model'; +import { Collection } from '../../../../core/shared/collection.model'; + +/** + * Class representing a community or collection role. + */ +export class ComcolRole { + + /** + * The community admin role. + */ + public static COMMUNITY_ADMIN = new ComcolRole( + 'community-admin', + 'adminGroup', + ); + + /** + * The collection admin role. + */ + public static COLLECTION_ADMIN = new ComcolRole( + 'collection-admin', + 'adminGroup', + ); + + /** + * The submitters role. + */ + public static SUBMITTERS = new ComcolRole( + 'submitters', + 'submittersGroup', + ); + + /** + * The default item read role. + */ + public static ITEM_READ = new ComcolRole( + 'item_read', + 'itemReadGroup', + ); + + /** + * The default bitstream read role. + */ + public static BITSTREAM_READ = new ComcolRole( + 'bitstream_read', + 'bitstreamReadGroup', + ); + + /** + * @param name The name for this community or collection role. + * @param linkName The path linking to this community or collection role. + */ + constructor( + public name, + public linkName, + ) { + } + + /** + * Get the REST endpoint for managing this role for a given community or collection. + * @param dso + */ + public getEndpoint(dso: Community | Collection) { + + let linkPath; + switch (dso.type + '') { + case 'community': + linkPath = 'communities'; + break; + case 'collection': + linkPath = 'collections'; + break; + } + + return `${linkPath}/${dso.uuid}/${this.linkName}`; + } +} diff --git a/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts b/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts index 0f9d4c55b4..2fa05fa28b 100644 --- a/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts +++ b/src/app/shared/comcol-forms/edit-comcol-page/edit-comcol-page.component.ts @@ -2,10 +2,8 @@ import { Component, OnInit } from '@angular/core'; import { Observable } from 'rxjs'; import { ActivatedRoute, Router } from '@angular/router'; import { RemoteData } from '../../../core/data/remote-data'; -import { isNotEmpty, isNotUndefined } from '../../empty.util'; +import { isNotEmpty } from '../../empty.util'; import { first, map } from 'rxjs/operators'; -import { getSucceededRemoteData } from '../../../core/shared/operators'; -import { DataService } from '../../../core/data/data.service'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; /** 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/comcol-page-logo/comcol-page-logo.component.html b/src/app/shared/comcol-page-logo/comcol-page-logo.component.html index 4bd7369f06..057c358223 100644 --- a/src/app/shared/comcol-page-logo/comcol-page-logo.component.html +++ b/src/app/shared/comcol-page-logo/comcol-page-logo.component.html @@ -1,3 +1,3 @@ diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.scss index 5b808b9cfd..4e58759f4e 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.scss +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.scss @@ -9,7 +9,7 @@ justify-content: center; } -:host /deep/ .custom-select { +:host ::ng-deep .custom-select { -webkit-appearance: none; -moz-appearance: none; appearance: none; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts index 91c1dbc085..75ea88735f 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.spec.ts @@ -160,7 +160,7 @@ describe('DsDynamicFormControlContainerComponent test suite', () => { imports: [ FormsModule, ReactiveFormsModule, - NgbModule.forRoot(), + NgbModule, DynamicFormsCoreModule.forRoot(), SharedModule, TranslateModule.forRoot(), diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts index 22376502e7..2089ce8bca 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/ds-dynamic-form-control-container.component.ts @@ -29,13 +29,13 @@ import { DYNAMIC_FORM_CONTROL_TYPE_SELECT, DYNAMIC_FORM_CONTROL_TYPE_TEXTAREA, DYNAMIC_FORM_CONTROL_TYPE_TIMEPICKER, - DynamicDatePickerModel, + DynamicDatePickerModel, DynamicFormComponentService, DynamicFormControl, DynamicFormControlContainerComponent, DynamicFormControlEvent, - DynamicFormControlModel, DynamicFormInstancesService, + DynamicFormControlModel, DynamicFormLayout, - DynamicFormLayoutService, + DynamicFormLayoutService, DynamicFormRelationService, DynamicFormValidationService, DynamicTemplateDirective, } from '@ng-dynamic-forms/core'; @@ -50,6 +50,7 @@ import { DynamicNGBootstrapTimePickerComponent } from '@ng-dynamic-forms/ui-ng-bootstrap'; import { TranslateService } from '@ngx-translate/core'; +import { followLink } from '../../../utils/follow-link-config.model'; import { Reorderable, ReorderableRelationship @@ -75,6 +76,8 @@ import { DsDynamicFormArrayComponent } from './models/array-group/dynamic-form-a import { DsDynamicRelationGroupComponent } from './models/relation-group/dynamic-relation-group.components'; import { DYNAMIC_FORM_CONTROL_TYPE_RELATION_GROUP } from './models/relation-group/dynamic-relation-group.model'; import { DsDatePickerInlineComponent } from './models/date-picker-inline/dynamic-date-picker-inline.component'; +import { DYNAMIC_FORM_CONTROL_TYPE_CUSTOM_SWITCH } from './models/custom-switch/custom-switch.model'; +import { CustomSwitchComponent } from './models/custom-switch/custom-switch.component'; import { map, startWith, switchMap, find } from 'rxjs/operators'; import { combineLatest as observableCombineLatest, Observable, of as observableOf, Subscription } from 'rxjs'; import { SearchResult } from '../../../search/search-result.model'; @@ -97,6 +100,7 @@ import { PaginatedList } from '../../../../core/data/paginated-list'; import { ItemSearchResult } from '../../../object-collection/shared/item-search-result.model'; import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; import { Relationship } from '../../../../core/shared/item-relationships/relationship.model'; +import { Collection } from '../../../../core/shared/collection.model'; export function dsDynamicFormControlMapFn(model: DynamicFormControlModel): Type | null { switch (model.type) { @@ -156,6 +160,9 @@ export function dsDynamicFormControlMapFn(model: DynamicFormControlModel): Type< case DYNAMIC_FORM_CONTROL_TYPE_DISABLED: return DsDynamicDisabledComponent; + case DYNAMIC_FORM_CONTROL_TYPE_CUSTOM_SWITCH: + return CustomSwitchComponent; + default: return null; } @@ -185,6 +192,7 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo hasRelationLookup: boolean; modalRef: NgbModalRef; item: Item; + collection: Collection; listId: string; searchConfig: string; @@ -199,32 +207,32 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo @Output('dfFocus') focus: EventEmitter = new EventEmitter(); @Output('ngbEvent') customEvent: EventEmitter = new EventEmitter(); /* tslint:enable:no-output-rename */ - @ViewChild('componentViewContainer', { read: ViewContainerRef }) componentViewContainerRef: ViewContainerRef; + @ViewChild('componentViewContainer', { read: ViewContainerRef, static: true}) componentViewContainerRef: ViewContainerRef; private showErrorMessagesPreviousStage: boolean; get componentType(): Type | null { - return this.layoutService.getCustomComponentType(this.model) || dsDynamicFormControlMapFn(this.model); + return dsDynamicFormControlMapFn(this.model); } constructor( protected componentFactoryResolver: ComponentFactoryResolver, - protected dynamicFormInstanceService: DynamicFormInstancesService, + protected dynamicFormComponentService: DynamicFormComponentService, protected layoutService: DynamicFormLayoutService, protected validationService: DynamicFormValidationService, protected translateService: TranslateService, + protected relationService: DynamicFormRelationService, private modalService: NgbModal, - private relationService: RelationshipService, + private relationshipService: RelationshipService, private selectableListService: SelectableListService, private itemService: ItemDataService, - private relationshipService: RelationshipService, private zone: NgZone, private store: Store, private submissionObjectService: SubmissionObjectDataService, private ref: ChangeDetectorRef ) { - super(componentFactoryResolver, layoutService, validationService, dynamicFormInstanceService); + super(componentFactoryResolver, layoutService, validationService, dynamicFormComponentService, relationService); } /** @@ -236,21 +244,20 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo if (this.hasRelationLookup) { this.listId = 'list-' + this.model.relationship.relationshipType; - const item$ = this.submissionObjectService - .findById(this.model.submissionId).pipe( + + const submissionObject$ = this.submissionObjectService + .findById(this.model.submissionId, followLink('item'), followLink('collection')).pipe( getAllSucceededRemoteData(), - getRemoteDataPayload(), - switchMap((submissionObject: SubmissionObject) => (submissionObject.item as Observable>) - .pipe( - getAllSucceededRemoteData(), - getRemoteDataPayload() - ) - ) + getRemoteDataPayload() ); + const item$ = submissionObject$.pipe(switchMap((submissionObject: SubmissionObject) => (submissionObject.item as Observable>).pipe(getAllSucceededRemoteData(), getRemoteDataPayload()))); + const collection$ = submissionObject$.pipe(switchMap((submissionObject: SubmissionObject) => (submissionObject.collection as Observable>).pipe(getAllSucceededRemoteData(), getRemoteDataPayload()))); + this.subs.push(item$.subscribe((item) => this.item = item)); + this.subs.push(collection$.subscribe((collection) => this.collection = collection)); this.reorderables$ = item$.pipe( - switchMap((item) => this.relationService.getItemRelationshipsByLabel(item, this.model.relationship.relationshipType) + switchMap((item) => this.relationshipService.getItemRelationshipsByLabel(item, this.model.relationship.relationshipType, undefined, followLink('leftItem'), followLink('rightItem'), followLink('relationshipType')) .pipe( getAllSucceededRemoteData(), getRemoteDataPayload(), @@ -282,12 +289,19 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo this.ref.detectChanges(); })); - this.relationService.getRelatedItemsByLabel(this.item, this.model.relationship.relationshipType).pipe( + item$.pipe( + switchMap((item) => this.relationshipService.getRelatedItemsByLabel(item, this.model.relationship.relationshipType)), map((items: RemoteData>) => items.payload.page.map((item) => Object.assign(new ItemSearchResult(), { indexableObject: item }))), - ).subscribe((relatedItems: Array>) => this.selectableListService.select(this.listId, relatedItems)); + ).subscribe((relatedItems: Array>) => { + this.selectableListService.select(this.listId, relatedItems) + }); } } + get isCheckbox(): boolean { + return this.model.type === DYNAMIC_FORM_CONTROL_TYPE_CHECKBOX || this.model.type === DYNAMIC_FORM_CONTROL_TYPE_CUSTOM_SWITCH; + } + ngOnChanges(changes: SimpleChanges) { if (changes) { super.ngOnChanges(changes); @@ -343,6 +357,7 @@ export class DsDynamicFormControlContainerComponent extends DynamicFormControlCo modalComp.label = this.model.label; modalComp.metadataFields = this.model.metadataFields; modalComp.item = this.item; + modalComp.collection = this.collection; } /** diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.html new file mode 100644 index 0000000000..9d059b4bee --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.html @@ -0,0 +1,20 @@ +
+ + +
diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.spec.ts new file mode 100644 index 0000000000..6c2502a92b --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.spec.ts @@ -0,0 +1,99 @@ +import { DynamicFormsCoreModule, DynamicFormService } from '@ng-dynamic-forms/core'; +import { FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { DebugElement } from '@angular/core'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TextMaskModule } from 'angular2-text-mask'; +import { By } from '@angular/platform-browser'; +import { DynamicCustomSwitchModel } from './custom-switch.model'; +import { CustomSwitchComponent } from './custom-switch.component'; + +describe('CustomSwitchComponent', () => { + + const testModel = new DynamicCustomSwitchModel({id: 'switch'}); + const formModel = [testModel]; + let formGroup: FormGroup; + let fixture: ComponentFixture; + let component: CustomSwitchComponent; + let debugElement: DebugElement; + let testElement: DebugElement; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + ReactiveFormsModule, + NoopAnimationsModule, + TextMaskModule, + DynamicFormsCoreModule.forRoot() + ], + declarations: [CustomSwitchComponent] + + }).compileComponents().then(() => { + fixture = TestBed.createComponent(CustomSwitchComponent); + + component = fixture.componentInstance; + debugElement = fixture.debugElement; + }); + })); + + beforeEach(inject([DynamicFormService], (service: DynamicFormService) => { + formGroup = service.createFormGroup(formModel); + + component.group = formGroup; + component.model = testModel; + + fixture.detectChanges(); + + testElement = debugElement.query(By.css(`input[id='${testModel.id}']`)); + })); + + it('should initialize correctly', () => { + expect(component.bindId).toBe(true); + expect(component.group instanceof FormGroup).toBe(true); + expect(component.model instanceof DynamicCustomSwitchModel).toBe(true); + + expect(component.blur).toBeDefined(); + expect(component.change).toBeDefined(); + expect(component.focus).toBeDefined(); + + expect(component.onBlur).toBeDefined(); + expect(component.onChange).toBeDefined(); + expect(component.onFocus).toBeDefined(); + + expect(component.hasFocus).toBe(false); + expect(component.isValid).toBe(true); + expect(component.isInvalid).toBe(false); + }); + + it('should have an input element', () => { + expect(testElement instanceof DebugElement).toBe(true); + }); + + it('should have an input element of type checkbox', () => { + expect(testElement.nativeElement.getAttribute('type')).toEqual('checkbox'); + }); + + it('should emit blur event', () => { + spyOn(component.blur, 'emit'); + + component.onBlur(null); + + expect(component.blur.emit).toHaveBeenCalled(); + }); + + it('should emit change event', () => { + spyOn(component.change, 'emit'); + + component.onChange(null); + + expect(component.change.emit).toHaveBeenCalled(); + }); + + it('should emit focus event', () => { + spyOn(component.focus, 'emit'); + + component.onFocus(null); + + expect(component.focus.emit).toHaveBeenCalled(); + }); +}); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.ts new file mode 100644 index 0000000000..ab02fc159d --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component.ts @@ -0,0 +1,55 @@ +import { DynamicNGBootstrapCheckboxComponent } from '@ng-dynamic-forms/ui-ng-bootstrap'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { DynamicCustomSwitchModel } from './custom-switch.model'; + +@Component({ + selector: 'ds-custom-switch', + styleUrls: ['./custom-switch.component.scss'], + templateUrl: './custom-switch.component.html', +}) +/** + * Component displaying a custom switch usable in dynamic forms + * Extends from bootstrap's checkbox component but displays a switch instead + */ +export class CustomSwitchComponent extends DynamicNGBootstrapCheckboxComponent { + /** + * Use the model's ID for the input element + */ + @Input() bindId = true; + + /** + * The formgroup containing this component + */ + @Input() group: FormGroup; + + /** + * The model used for displaying the switch + */ + @Input() model: DynamicCustomSwitchModel; + + /** + * Emit an event when the input is selected + */ + @Output() selected = new EventEmitter(); + + /** + * Emit an event when the input value is removed + */ + @Output() remove = new EventEmitter(); + + /** + * Emit an event when the input is blurred out + */ + @Output() blur = new EventEmitter(); + + /** + * Emit an event when the input value changes + */ + @Output() change = new EventEmitter(); + + /** + * Emit an event when the input is focused + */ + @Output() focus = new EventEmitter(); +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.model.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.model.ts new file mode 100644 index 0000000000..97cf71c4a0 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.model.ts @@ -0,0 +1,20 @@ +import { + DynamicCheckboxModel, + DynamicCheckboxModelConfig, + DynamicFormControlLayout, + serializable +} from '@ng-dynamic-forms/core'; + +export const DYNAMIC_FORM_CONTROL_TYPE_CUSTOM_SWITCH = 'CUSTOM_SWITCH'; + +/** + * Model class for displaying a custom switch input in a form + * Functions like a checkbox, but displays a switch instead + */ +export class DynamicCustomSwitchModel extends DynamicCheckboxModel { + @serializable() readonly type: string = DYNAMIC_FORM_CONTROL_TYPE_CUSTOM_SWITCH; + + constructor(config: DynamicCheckboxModelConfig, layout?: DynamicFormControlLayout) { + super(config, layout); + } +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker-inline/dynamic-date-picker-inline.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker-inline/dynamic-date-picker-inline.component.spec.ts index da7f5637dd..327859e0ea 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker-inline/dynamic-date-picker-inline.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker-inline/dynamic-date-picker-inline.component.spec.ts @@ -26,7 +26,7 @@ describe('DsDatePickerInlineComponent test suite', () => { ReactiveFormsModule, NoopAnimationsModule, TextMaskModule, - NgbDatepickerModule.forRoot(), + NgbDatepickerModule, DynamicFormsCoreModule.forRoot() ], declarations: [DsDatePickerInlineComponent] diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker-inline/dynamic-date-picker-inline.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker-inline/dynamic-date-picker-inline.component.ts index f51c2f78f4..73375a85a2 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker-inline/dynamic-date-picker-inline.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker-inline/dynamic-date-picker-inline.component.ts @@ -24,7 +24,7 @@ export class DsDatePickerInlineComponent extends DynamicFormControlComponent { @Output() change: EventEmitter = new EventEmitter(); @Output() focus: EventEmitter = new EventEmitter(); - @ViewChild(NgbDatepicker) ngbDatePicker: NgbDatepicker; + @ViewChild(NgbDatepicker, {static: false}) ngbDatePicker: NgbDatepicker; constructor(protected layoutService: DynamicFormLayoutService, protected validationService: DynamicFormValidationService, diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.spec.ts index 8f40c4f85f..ad54925880 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component.spec.ts @@ -43,7 +43,7 @@ describe('DsDatePickerComponent test suite', () => { TestBed.configureTestingModule({ imports: [ - NgbModule.forRoot() + NgbModule ], declarations: [ DsDatePickerComponent, diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.spec.ts index 1b3bcb5a59..e312444625 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/list/dynamic-list.component.spec.ts @@ -88,7 +88,7 @@ describe('DsDynamicListComponent test suite', () => { DynamicFormsNGBootstrapUIModule, FormsModule, ReactiveFormsModule, - NgbModule.forRoot() + NgbModule ], declarations: [ DsDynamicListComponent, diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.scss index 3af258db79..e1ba2442e5 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.scss +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.scss @@ -2,7 +2,7 @@ display:none } -:host /deep/ .dropdown-menu { +:host ::ng-deep .dropdown-menu { left: 0 !important; width: 100% !important; max-height: $dropdown-menu-max-height; @@ -10,10 +10,10 @@ overflow-x: hidden; } -:host /deep/ .dropdown-item.active, -:host /deep/ .dropdown-item:active, -:host /deep/ .dropdown-item:focus, -:host /deep/ .dropdown-item:hover { +:host ::ng-deep .dropdown-item.active, +:host ::ng-deep .dropdown-item:active, +:host ::ng-deep .dropdown-item:focus, +:host ::ng-deep .dropdown-item:hover { color: $dropdown-link-hover-color !important; background-color: $dropdown-link-hover-bg !important; } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts index b0ed3a1dc2..c1f8ad69ba 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component.spec.ts @@ -160,7 +160,7 @@ describe('Dynamic Lookup component', () => { FormsModule, InfiniteScrollModule, ReactiveFormsModule, - NgbModule.forRoot(), + NgbModule, TranslateModule.forRoot() ], declarations: [ diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts index 75d30d9d79..eb0f8f76f9 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.component.spec.ts @@ -113,7 +113,7 @@ describe('DsDynamicRelationGroupComponent test suite', () => { BrowserAnimationsModule, FormsModule, ReactiveFormsModule, - NgbModule.forRoot(), + NgbModule, StoreModule.forRoot({}), TranslateModule.forRoot() ], diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts index ea62eeb4ce..5f96e957ac 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components.ts @@ -65,7 +65,7 @@ export class DsDynamicRelationGroupComponent extends DynamicFormControlComponent private selectedChipItem: ChipsItem; private subs: Subscription[] = []; - @ViewChild('formRef') private formRef: FormComponent; + @ViewChild('formRef', {static: false}) private formRef: FormComponent; constructor(@Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, private authorityService: AuthorityService, diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts index ab923a58fa..16446e624e 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/scrollable-dropdown/dynamic-scrollable-dropdown.component.spec.ts @@ -64,7 +64,7 @@ describe('Dynamic Dynamic Scrollable Dropdown component', () => { FormsModule, InfiniteScrollModule, ReactiveFormsModule, - NgbModule.forRoot(), + NgbModule, TranslateModule.forRoot() ], declarations: [ diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.scss index a657d3eeb6..032596207a 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.scss +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.scss @@ -8,7 +8,7 @@ padding-right: 100%; } -:host /deep/ .dropdown-menu { +:host ::ng-deep .dropdown-menu { width: 100% !important; max-height: $dropdown-menu-max-height; overflow-y: scroll; @@ -17,10 +17,10 @@ margin-top: $spacer !important; } -:host /deep/ .dropdown-item.active, -:host /deep/ .dropdown-item:active, -:host /deep/ .dropdown-item:focus, -:host /deep/ .dropdown-item:hover { +:host ::ng-deep .dropdown-item.active, +:host ::ng-deep .dropdown-item:active, +:host ::ng-deep .dropdown-item:focus, +:host ::ng-deep .dropdown-item:hover { color: $dropdown-link-hover-color !important; background-color: $dropdown-link-hover-bg !important; } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts index 9aeada5032..dcc4eaf9c9 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.spec.ts @@ -85,7 +85,7 @@ describe('DsDynamicTagComponent test suite', () => { DynamicFormsCoreModule, DynamicFormsNGBootstrapUIModule, FormsModule, - NgbModule.forRoot(), + NgbModule, ReactiveFormsModule, ], declarations: [ diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts index a44a20d4bd..c976454dd9 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/tag/dynamic-tag.component.ts @@ -33,7 +33,7 @@ export class DsDynamicTagComponent extends DynamicFormControlComponent implement @Output() change: EventEmitter = new EventEmitter(); @Output() focus: EventEmitter = new EventEmitter(); - @ViewChild('instance') instance: NgbTypeahead; + @ViewChild('instance', {static: false}) instance: NgbTypeahead; chips: Chips; hasAuthority: boolean; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.scss index fe20afe1ce..3857d96e78 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.scss +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.scss @@ -1,18 +1,18 @@ -:host /deep/ .dropdown-menu { +:host ::ng-deep .dropdown-menu { width: 100% !important; max-height: $dropdown-menu-max-height; overflow-y: auto !important; overflow-x: hidden; } -:host /deep/ .dropdown-item { +:host ::ng-deep .dropdown-item { border-bottom: $dropdown-border-width solid $dropdown-border-color; } -:host /deep/ .dropdown-item.active, -:host /deep/ .dropdown-item:active, -:host /deep/ .dropdown-item:focus, -:host /deep/ .dropdown-item:hover { +:host ::ng-deep .dropdown-item.active, +:host ::ng-deep .dropdown-item:active, +:host ::ng-deep .dropdown-item:focus, +:host ::ng-deep .dropdown-item:hover { color: $dropdown-link-hover-color !important; background-color: $dropdown-link-hover-bg !important; } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.spec.ts index 47b83ed2f9..4b1e2d8703 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.spec.ts @@ -70,7 +70,7 @@ describe('DsDynamicTypeaheadComponent test suite', () => { DynamicFormsCoreModule, DynamicFormsNGBootstrapUIModule, FormsModule, - NgbModule.forRoot(), + NgbModule, ReactiveFormsModule, TranslateModule.forRoot() ], diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.ts index 136d1db1c2..791704a7ca 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/models/typeahead/dynamic-typeahead.component.ts @@ -31,7 +31,7 @@ export class DsDynamicTypeaheadComponent extends DynamicFormControlComponent imp @Output() change: EventEmitter = new EventEmitter(); @Output() focus: EventEmitter = new EventEmitter(); - @ViewChild('instance') instance: NgbTypeahead; + @ViewChild('instance', {static: false}) instance: NgbTypeahead; searching = false; searchOptions: IntegrationSearchOptions; diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.html index 46620aa00b..328cdc6763 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.html @@ -25,12 +25,14 @@ [title]="'submission.sections.describe.relationship-lookup.search-tab.tab-title.' + source.id | translate : {count: (totalExternal$ | async)[idx]}"> diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.scss index 4fb77a7590..42c94c1f68 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.scss +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.scss @@ -1,3 +1,11 @@ .modal-footer { justify-content: space-between; } + +/* Render child-modals slightly smaller than this modal to avoid complete overlap */ +:host { + ::ng-deep .modal-content { + width: 90%; + margin: 5%; + } +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.spec.ts index d1b289bf11..3fbe372699 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.spec.ts @@ -79,7 +79,7 @@ describe('DsDynamicLookupRelationModalComponent', () => { init(); TestBed.configureTestingModule({ declarations: [DsDynamicLookupRelationModalComponent], - imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NgbModule.forRoot()], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NgbModule], providers: [ { provide: SearchConfigurationService, useValue: { diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.ts index bce1f53c4d..edf54bf08b 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component.ts @@ -66,6 +66,11 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy */ item; + /** + * The collection we're submitting an item to + */ + collection; + /** * Is the selection repeatable? */ @@ -233,6 +238,15 @@ export class DsDynamicLookupRelationModalComponent implements OnInit, OnDestroy ) } + /** + * Called when an external object has been imported, resets the total values and adds the object to the selected list + * @param object + */ + imported(object) { + this.setTotals(); + this.select(object); + } + /** * Calculate and set the total entries available for each tab */ diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.html index 9536d0a5cb..04737c44e4 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.html +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.html @@ -10,13 +10,13 @@ + [importable]="true" + [importConfig]="importConfig" + (importObject)="import($event)"> diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.spec.ts index 62327e236e..06a0cf2ac7 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.spec.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.spec.ts @@ -3,7 +3,7 @@ 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 { EventEmitter, NO_ERRORS_SCHEMA } from '@angular/core'; import { PaginatedSearchOptions } from '../../../../../search/paginated-search-options.model'; import { SearchConfigurationService } from '../../../../../../core/shared/search/search-configuration.service'; import { of as observableOf } from 'rxjs/internal/observable/of'; @@ -18,12 +18,20 @@ import { ExternalSource } from '../../../../../../core/shared/external-source.mo import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { By } from '@angular/platform-browser'; import { ExternalSourceEntry } from '../../../../../../core/shared/external-source-entry.model'; +import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { SelectableListService } from '../../../../../object-list/selectable-list/selectable-list.service'; +import { Item } from '../../../../../../core/shared/item.model'; +import { Collection } from '../../../../../../core/shared/collection.model'; +import { RelationshipOptions } from '../../../models/relationship-options.model'; +import { ExternalSourceEntryImportModalComponent } from './external-source-entry-import-modal/external-source-entry-import-modal.component'; describe('DsDynamicLookupRelationExternalSourceTabComponent', () => { let component: DsDynamicLookupRelationExternalSourceTabComponent; let fixture: ComponentFixture; let pSearchOptions; let externalSourceService; + let selectableListService; + let modalService; const externalSource = { id: 'orcidV2', @@ -68,6 +76,10 @@ describe('DsDynamicLookupRelationExternalSourceTabComponent', () => { } }) ] as ExternalSourceEntry[]; + const item = Object.assign(new Item(), { id: 'submission-item' }); + const collection = Object.assign(new Collection(), { id: 'submission-collection' }); + const relationship = Object.assign(new RelationshipOptions(), { relationshipType: 'isAuthorOfPublication' }); + const label = 'Author'; function init() { pSearchOptions = new PaginatedSearchOptions({ @@ -76,20 +88,22 @@ describe('DsDynamicLookupRelationExternalSourceTabComponent', () => { externalSourceService = jasmine.createSpyObj('externalSourceService', { getExternalSourceEntries: createSuccessfulRemoteDataObject$(createPaginatedList(externalEntries)) }); + selectableListService = jasmine.createSpyObj('selectableListService', ['selectSingle']); } beforeEach(async(() => { init(); TestBed.configureTestingModule({ declarations: [DsDynamicLookupRelationExternalSourceTabComponent, VarDirective], - imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), BrowserAnimationsModule], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NgbModule, BrowserAnimationsModule], providers: [ { provide: SearchConfigurationService, useValue: { paginatedSearchOptions: observableOf(pSearchOptions) } }, - { provide: ExternalSourceService, useValue: externalSourceService } + { provide: ExternalSourceService, useValue: externalSourceService }, + { provide: SelectableListService, useValue: selectableListService } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -99,13 +113,18 @@ describe('DsDynamicLookupRelationExternalSourceTabComponent', () => { fixture = TestBed.createComponent(DsDynamicLookupRelationExternalSourceTabComponent); component = fixture.componentInstance; component.externalSource = externalSource; + component.item = item; + component.collection = collection; + component.relationship = relationship; + component.label = label; + modalService = (component as any).modalService; fixture.detectChanges(); }); describe('when the external entries finished loading successfully', () => { it('should display a ds-viewable-collection component', () => { - const collection = fixture.debugElement.query(By.css('ds-viewable-collection')); - expect(collection).toBeDefined(); + const viewableCollection = fixture.debugElement.query(By.css('ds-viewable-collection')); + expect(viewableCollection).toBeDefined(); }); }); @@ -116,8 +135,8 @@ describe('DsDynamicLookupRelationExternalSourceTabComponent', () => { }); it('should not display a ds-viewable-collection component', () => { - const collection = fixture.debugElement.query(By.css('ds-viewable-collection')); - expect(collection).toBeNull(); + const viewableCollection = fixture.debugElement.query(By.css('ds-viewable-collection')); + expect(viewableCollection).toBeNull(); }); it('should display a ds-loading component', () => { @@ -133,8 +152,8 @@ describe('DsDynamicLookupRelationExternalSourceTabComponent', () => { }); it('should not display a ds-viewable-collection component', () => { - const collection = fixture.debugElement.query(By.css('ds-viewable-collection')); - expect(collection).toBeNull(); + const viewableCollection = fixture.debugElement.query(By.css('ds-viewable-collection')); + expect(viewableCollection).toBeNull(); }); it('should display a ds-error component', () => { @@ -150,8 +169,8 @@ describe('DsDynamicLookupRelationExternalSourceTabComponent', () => { }); it('should not display a ds-viewable-collection component', () => { - const collection = fixture.debugElement.query(By.css('ds-viewable-collection')); - expect(collection).toBeNull(); + const viewableCollection = fixture.debugElement.query(By.css('ds-viewable-collection')); + expect(viewableCollection).toBeNull(); }); it('should display a message the list is empty', () => { @@ -159,4 +178,15 @@ describe('DsDynamicLookupRelationExternalSourceTabComponent', () => { expect(empty).not.toBeNull(); }); }); + + describe('import', () => { + beforeEach(() => { + spyOn(modalService, 'open').and.returnValue(Object.assign({ componentInstance: Object.assign({ importedObject: new EventEmitter() }) })); + component.import(externalEntries[0]); + }); + + it('should open a new ExternalSourceEntryImportModalComponent', () => { + expect(modalService.open).toHaveBeenCalledWith(ExternalSourceEntryImportModalComponent, jasmine.any(Object)) + }); + }); }); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.ts index d1fa538de3..c8b3b3d311 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component.ts @@ -1,4 +1,4 @@ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { SEARCH_CONFIG_SERVICE } from '../../../../../../+my-dspace-page/my-dspace-page.component'; import { SearchConfigurationService } from '../../../../../../core/shared/search/search-configuration.service'; import { Router } from '@angular/router'; @@ -14,6 +14,14 @@ import { Context } from '../../../../../../core/shared/context.model'; import { ListableObject } from '../../../../../object-collection/shared/listable-object.model'; import { fadeIn, fadeInOut } from '../../../../../animations/fade'; import { PaginationComponentOptions } from '../../../../../pagination/pagination-component-options.model'; +import { RelationshipOptions } from '../../../models/relationship-options.model'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { ExternalSourceEntryImportModalComponent } from './external-source-entry-import-modal/external-source-entry-import-modal.component'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { hasValue } from '../../../../../empty.util'; +import { SelectableListService } from '../../../../../object-list/selectable-list/selectable-list.service'; +import { Item } from '../../../../../../core/shared/item.model'; +import { Collection } from '../../../../../../core/shared/collection.model'; @Component({ selector: 'ds-dynamic-lookup-relation-external-source-tab', @@ -31,11 +39,12 @@ import { PaginationComponentOptions } from '../../../../../pagination/pagination ] }) /** - * The tab displaying a list of importable entries for an external source + * Component rendering the tab content of an external source during submission lookup + * Shows a list of entries matching the current search query with the option to import them into the repository */ -export class DsDynamicLookupRelationExternalSourceTabComponent implements OnInit { +export class DsDynamicLookupRelationExternalSourceTabComponent implements OnInit, OnDestroy { /** - * The label to use to display i18n messages (describing the type of relationship) + * The label to use for all messages (added to the end of relevant i18n keys) */ @Input() label: string; @@ -45,27 +54,32 @@ export class DsDynamicLookupRelationExternalSourceTabComponent implements OnInit @Input() listId: string; /** - * Is the selection repeatable? + * The item in submission */ - @Input() repeatable: boolean; + @Input() item: Item; /** - * The context to display lists + * The collection the user is submitting an item into + */ + @Input() collection: Collection; + + /** + * The relationship-options for the current lookup + */ + @Input() relationship: RelationshipOptions; + + /** + * The context to displaying lists for */ @Input() context: Context; /** - * Send an event to deselect an object from the list + * Emit an event when an object has been imported (or selected from similar local entries) */ - @Output() deselectObject: EventEmitter = new EventEmitter(); + @Output() importedObject: EventEmitter = new EventEmitter(); /** - * Send an event to select an object from the list - */ - @Output() selectObject: EventEmitter = new EventEmitter(); - - /** - * The initial pagination to start with + * The initial pagination options */ initialPagination = Object.assign(new PaginationComponentOptions(), { id: 'submission-external-source-relation-list', @@ -82,15 +96,68 @@ export class DsDynamicLookupRelationExternalSourceTabComponent implements OnInit */ entriesRD$: Observable>>; + /** + * Config to use for the import buttons + */ + importConfig; + + /** + * The modal for importing the entry + */ + modalRef: NgbModalRef; + + /** + * Subscription to the modal's importedObject event-emitter + */ + importObjectSub: Subscription; + constructor(private router: Router, public searchConfigService: SearchConfigurationService, - private externalSourceService: ExternalSourceService) { + private externalSourceService: ExternalSourceService, + private modalService: NgbModal, + private selectableListService: SelectableListService) { } + /** + * Get the entries for the selected external source + */ ngOnInit(): void { this.entriesRD$ = this.searchConfigService.paginatedSearchOptions.pipe( switchMap((searchOptions: PaginatedSearchOptions) => this.externalSourceService.getExternalSourceEntries(this.externalSource.id, searchOptions).pipe(startWith(undefined))) - ) + ); + this.importConfig = { + buttonLabel: 'submission.sections.describe.relationship-lookup.external-source.import-button-title.' + this.label + }; + } + + /** + * Start the import of an entry by opening up an import modal window + * @param entry The entry to import + */ + import(entry) { + this.modalRef = this.modalService.open(ExternalSourceEntryImportModalComponent, { + size: 'lg', + container: 'ds-dynamic-lookup-relation-modal' + }); + const modalComp = this.modalRef.componentInstance; + modalComp.externalSourceEntry = entry; + modalComp.item = this.item; + modalComp.collection = this.collection; + modalComp.relationship = this.relationship; + modalComp.label = this.label; + this.importObjectSub = modalComp.importedObject.subscribe((object) => { + this.selectableListService.selectSingle(this.listId, object); + this.importedObject.emit(object); + }); + } + + /** + * Unsubscribe from open subscriptions + */ + ngOnDestroy(): void { + if (hasValue(this.importObjectSub)) { + this.importObjectSub.unsubscribe(); + } } } diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.html b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.html new file mode 100644 index 0000000000..a4fc356ef9 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.html @@ -0,0 +1,61 @@ + + + diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.scss b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.scss new file mode 100644 index 0000000000..7db9839e38 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.scss @@ -0,0 +1,3 @@ +.modal-footer { + justify-content: space-between; +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.spec.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.spec.ts new file mode 100644 index 0000000000..264b3f945a --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.spec.ts @@ -0,0 +1,194 @@ +import { ExternalSourceEntryImportModalComponent, ImportType } from './external-source-entry-import-modal.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 { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'; +import { LookupRelationService } from '../../../../../../../core/data/lookup-relation.service'; +import { ExternalSourceEntry } from '../../../../../../../core/shared/external-source-entry.model'; +import { Item } from '../../../../../../../core/shared/item.model'; +import { ItemSearchResult } from '../../../../../../object-collection/shared/item-search-result.model'; +import { createPaginatedList, createSuccessfulRemoteDataObject$ } from '../../../../../../testing/utils'; +import { Collection } from '../../../../../../../core/shared/collection.model'; +import { RelationshipOptions } from '../../../../models/relationship-options.model'; +import { SelectableListService } from '../../../../../../object-list/selectable-list/selectable-list.service'; +import { ItemDataService } from '../../../../../../../core/data/item-data.service'; +import { NotificationsService } from '../../../../../../notifications/notifications.service'; + +describe('DsDynamicLookupRelationExternalSourceTabComponent', () => { + let component: ExternalSourceEntryImportModalComponent; + let fixture: ComponentFixture; + let lookupRelationService: LookupRelationService; + let selectService: SelectableListService; + let itemService: ItemDataService; + let notificationsService: NotificationsService; + let modalStub: NgbActiveModal; + + const uri = 'https://orcid.org/0001-0001-0001-0001'; + const entry = Object.assign(new ExternalSourceEntry(), { + id: '0001-0001-0001-0001', + display: 'John Doe', + value: 'John, Doe', + metadata: { + 'dc.identifier.uri': [ + { + value: uri + } + ] + } + }); + + const label = 'Author'; + const relationship = Object.assign(new RelationshipOptions(), { relationshipType: 'isAuthorOfPublication' }); + const submissionCollection = Object.assign(new Collection(), { uuid: '9398affe-a977-4992-9a1d-6f00908a259f' }); + const submissionItem = Object.assign(new Item(), { uuid: '26224069-5f99-412a-9e9b-7912a7e35cb1' }); + const item1 = Object.assign(new Item(), { uuid: 'e1c51c69-896d-42dc-8221-1d5f2ad5516e' }); + const item2 = Object.assign(new Item(), { uuid: 'c8279647-1acc-41ae-b036-951d5f65649b' }); + const item3 = Object.assign(new Item(), { uuid: 'c3bcbff5-ec0c-4831-8e4c-94b9c933ccac' }); + const searchResult1 = Object.assign(new ItemSearchResult(), { indexableObject: item1 }); + const searchResult2 = Object.assign(new ItemSearchResult(), { indexableObject: item2 }); + const searchResult3 = Object.assign(new ItemSearchResult(), { indexableObject: item3 }); + const importedItem = Object.assign(new Item(), { uuid: '5d0098fc-344a-4067-a57d-457092b72e82' }); + + function init() { + lookupRelationService = jasmine.createSpyObj('lookupRelationService', { + getLocalResults: createSuccessfulRemoteDataObject$(createPaginatedList([searchResult1, searchResult2, searchResult3])), + removeLocalResultsCache: {} + }); + selectService = jasmine.createSpyObj('selectService', ['deselectAll']); + notificationsService = jasmine.createSpyObj('notificationsService', ['success']); + itemService = jasmine.createSpyObj('itemService', { + importExternalSourceEntry: createSuccessfulRemoteDataObject$(importedItem) + }); + modalStub = jasmine.createSpyObj('modal', ['close']); + } + + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + declarations: [ExternalSourceEntryImportModalComponent], + imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NgbModule], + providers: [ + { provide: LookupRelationService, useValue: lookupRelationService }, + { provide: SelectableListService, useValue: selectService }, + { provide: NotificationsService, useValue: notificationsService }, + { provide: ItemDataService, useValue: itemService }, + { provide: NgbActiveModal, useValue: modalStub } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ExternalSourceEntryImportModalComponent); + component = fixture.componentInstance; + component.externalSourceEntry = entry; + component.label = label; + component.relationship = relationship; + component.collection = submissionCollection; + component.item = submissionItem; + fixture.detectChanges(); + }); + + describe('close', () => { + beforeEach(() => { + component.close(); + }); + + it('should close the modal', () => { + expect(modalStub.close).toHaveBeenCalled(); + }); + }); + + describe('selectEntity', () => { + const entity = Object.assign(new Item(), { uuid: 'd8698de5-5b05-4ea4-9d02-da73803a50f9' }); + + beforeEach(() => { + component.selectEntity(entity); + }); + + it('should set selected entity', () => { + expect(component.selectedEntity).toBe(entity); + }); + + it('should set the import type to local entity', () => { + expect(component.selectedImportType).toEqual(ImportType.LocalEntity); + }); + }); + + describe('deselectEntity', () => { + const entity = Object.assign(new Item(), { uuid: 'd8698de5-5b05-4ea4-9d02-da73803a50f9' }); + + beforeEach(() => { + component.selectedImportType = ImportType.LocalEntity; + component.selectedEntity = entity; + component.deselectEntity(); + }); + + it('should remove the selected entity', () => { + expect(component.selectedEntity).toBeUndefined(); + }); + + it('should set the import type to none', () => { + expect(component.selectedImportType).toEqual(ImportType.None); + }); + }); + + describe('selectNewEntity', () => { + describe('when current import type is set to new entity', () => { + beforeEach(() => { + component.selectedImportType = ImportType.NewEntity; + component.selectNewEntity(); + }); + + it('should set the import type to none', () => { + expect(component.selectedImportType).toEqual(ImportType.None); + }); + }); + + describe('when current import type is not set to new entity', () => { + beforeEach(() => { + component.selectedImportType = ImportType.None; + component.selectNewEntity(); + }); + + it('should set the import type to new entity', () => { + expect(component.selectedImportType).toEqual(ImportType.NewEntity); + }); + + it('should deselect the entity and authority list', () => { + expect(selectService.deselectAll).toHaveBeenCalledWith(component.entityListId); + expect(selectService.deselectAll).toHaveBeenCalledWith(component.authorityListId); + }); + }); + }); + + describe('selectNewAuthority', () => { + describe('when current import type is set to new authority', () => { + beforeEach(() => { + component.selectedImportType = ImportType.NewAuthority; + component.selectNewAuthority(); + }); + + it('should set the import type to none', () => { + expect(component.selectedImportType).toEqual(ImportType.None); + }); + }); + + describe('when current import type is not set to new authority', () => { + beforeEach(() => { + component.selectedImportType = ImportType.None; + component.selectNewAuthority(); + }); + + it('should set the import type to new authority', () => { + expect(component.selectedImportType).toEqual(ImportType.NewAuthority); + }); + + it('should deselect the entity and authority list', () => { + expect(selectService.deselectAll).toHaveBeenCalledWith(component.entityListId); + expect(selectService.deselectAll).toHaveBeenCalledWith(component.authorityListId); + }); + }); + }); +}); diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.ts new file mode 100644 index 0000000000..7e0fe78717 --- /dev/null +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component.ts @@ -0,0 +1,311 @@ +import { Component, EventEmitter, OnInit } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { ExternalSourceEntry } from '../../../../../../../core/shared/external-source-entry.model'; +import { MetadataValue } from '../../../../../../../core/shared/metadata.models'; +import { Metadata } from '../../../../../../../core/shared/metadata.utils'; +import { Observable } from 'rxjs/internal/Observable'; +import { RemoteData } from '../../../../../../../core/data/remote-data'; +import { PaginatedList } from '../../../../../../../core/data/paginated-list'; +import { SearchResult } from '../../../../../../search/search-result.model'; +import { Item } from '../../../../../../../core/shared/item.model'; +import { RelationshipOptions } from '../../../../models/relationship-options.model'; +import { LookupRelationService } from '../../../../../../../core/data/lookup-relation.service'; +import { PaginatedSearchOptions } from '../../../../../../search/paginated-search-options.model'; +import { CollectionElementLinkType } from '../../../../../../object-collection/collection-element-link.type'; +import { Context } from '../../../../../../../core/shared/context.model'; +import { SelectableListService } from '../../../../../../object-list/selectable-list/selectable-list.service'; +import { ListableObject } from '../../../../../../object-collection/shared/listable-object.model'; +import { Collection } from '../../../../../../../core/shared/collection.model'; +import { ItemDataService } from '../../../../../../../core/data/item-data.service'; +import { PaginationComponentOptions } from '../../../../../../pagination/pagination-component-options.model'; +import { getRemoteDataPayload, getSucceededRemoteData } from '../../../../../../../core/shared/operators'; +import { take } from 'rxjs/operators'; +import { ItemSearchResult } from '../../../../../../object-collection/shared/item-search-result.model'; +import { NotificationsService } from '../../../../../../notifications/notifications.service'; +import { TranslateService } from '@ngx-translate/core'; + +/** + * The possible types of import for the external entry + */ +export enum ImportType { + None = 'None', + LocalEntity = 'LocalEntity', + LocalAuthority = 'LocalAuthority', + NewEntity = 'NewEntity', + NewAuthority = 'NewAuthority' +} + +@Component({ + selector: 'ds-external-source-entry-import-modal', + styleUrls: ['./external-source-entry-import-modal.component.scss'], + templateUrl: './external-source-entry-import-modal.component.html' +}) +/** + * Component to display a modal window for importing an external source entry + * Shows information about the selected entry and a selectable list of local entities and authorities with similar names + * and the ability to add one of those results to the selection instead of the external entry. + * The other option is to import the external entry as a new entity or authority into the repository. + */ +export class ExternalSourceEntryImportModalComponent implements OnInit { + /** + * The prefix for every i18n key within this modal + */ + labelPrefix = 'submission.sections.describe.relationship-lookup.external-source.import-modal.'; + + /** + * The label to use for all messages (added to the end of relevant i18n keys) + */ + label: string; + + /** + * The external source entry + */ + externalSourceEntry: ExternalSourceEntry; + + /** + * The item in submission + */ + item: Item; + + /** + * The collection the user is submitting in + */ + collection: Collection; + + /** + * The ID of the collection to import entries to + */ + collectionId: string; + + /** + * The current relationship-options used for filtering results + */ + relationship: RelationshipOptions; + + /** + * The metadata value for the entry's uri + */ + uri: MetadataValue; + + /** + * Local entities with a similar name + */ + localEntitiesRD$: Observable>>>; + + /** + * Search options to use for fetching similar results + */ + searchOptions: PaginatedSearchOptions; + + /** + * The type of link to render in listable elements + */ + linkTypes = CollectionElementLinkType; + + /** + * The context we're currently in (submission) + */ + context = Context.SubmissionModal; + + /** + * List ID for selecting local entities + */ + entityListId = 'external-source-import-entity'; + + /** + * List ID for selecting local authorities + */ + authorityListId = 'external-source-import-authority'; + + /** + * ImportType enum + */ + importType = ImportType; + + /** + * The type of import the user currently has selected + */ + selectedImportType = ImportType.None; + + /** + * The selected local entity + */ + selectedEntity: ListableObject; + + /** + * The selected local authority + */ + selectedAuthority: ListableObject; + + /** + * An object has been imported, send it to the parent component + */ + importedObject: EventEmitter = new EventEmitter(); + + /** + * Should it display the ability to import the entry as an authority? + */ + authorityEnabled = false; + + constructor(public modal: NgbActiveModal, + public lookupRelationService: LookupRelationService, + private selectService: SelectableListService, + private itemService: ItemDataService, + private notificationsService: NotificationsService, + private translateService: TranslateService) { + } + + ngOnInit(): void { + this.uri = Metadata.first(this.externalSourceEntry.metadata, 'dc.identifier.uri'); + const pagination = Object.assign(new PaginationComponentOptions(), { id: 'external-entry-import', pageSize: 5 }); + this.searchOptions = Object.assign(new PaginatedSearchOptions({ query: this.externalSourceEntry.value, pagination: pagination })); + this.localEntitiesRD$ = this.lookupRelationService.getLocalResults(this.relationship, this.searchOptions); + this.collectionId = this.collection.id; + } + + /** + * Close the window + */ + close() { + this.modal.close(); + } + + /** + * Perform the import of the external entry + */ + import() { + switch (this.selectedImportType) { + case ImportType.LocalEntity : { + this.importLocalEntity(); + break; + } + case ImportType.NewEntity : { + this.importNewEntity(); + break; + } + case ImportType.LocalAuthority : { + this.importLocalAuthority(); + break; + } + case ImportType.NewAuthority : { + this.importNewAuthority(); + break; + } + } + this.selectedImportType = ImportType.None; + this.deselectAllLists(); + this.close(); + } + + /** + * Import the selected local entity + */ + importLocalEntity() { + if (this.selectedEntity !== undefined) { + this.notificationsService.success(this.translateService.get(this.labelPrefix + this.label + '.added.local-entity')); + this.importedObject.emit(this.selectedEntity); + } + } + + /** + * Create and import a new entity from the external entry + */ + importNewEntity() { + this.itemService.importExternalSourceEntry(this.externalSourceEntry, this.collectionId).pipe( + getSucceededRemoteData(), + getRemoteDataPayload(), + take(1) + ).subscribe((item: Item) => { + this.lookupRelationService.removeLocalResultsCache(); + const searchResult = Object.assign(new ItemSearchResult(), { + indexableObject: item + }); + this.notificationsService.success(this.translateService.get(this.labelPrefix + this.label + '.added.new-entity')); + this.importedObject.emit(searchResult); + }); + } + + /** + * Import the selected local authority + */ + importLocalAuthority() { + // TODO: Implement ability to import local authorities + } + + /** + * Create and import a new authority from the external entry + */ + importNewAuthority() { + // TODO: Implement ability to import new authorities + } + + /** + * Deselected a local entity + */ + deselectEntity() { + this.selectedEntity = undefined; + if (this.selectedImportType === ImportType.LocalEntity) { + this.selectedImportType = ImportType.None; + } + } + + /** + * Selected a local entity + * @param entity + */ + selectEntity(entity) { + this.selectedEntity = entity; + this.selectedImportType = ImportType.LocalEntity; + } + + /** + * Selected/deselected the new entity option + */ + selectNewEntity() { + if (this.selectedImportType === ImportType.NewEntity) { + this.selectedImportType = ImportType.None; + } else { + this.selectedImportType = ImportType.NewEntity; + this.deselectAllLists(); + } + } + + /** + * Deselected a local authority + */ + deselectAuthority() { + this.selectedAuthority = undefined; + if (this.selectedImportType === ImportType.LocalAuthority) { + this.selectedImportType = ImportType.None; + } + } + + /** + * Selected a local authority + * @param authority + */ + selectAuthority(authority) { + this.selectedAuthority = authority; + this.selectedImportType = ImportType.LocalAuthority; + } + + /** + * Selected/deselected the new authority option + */ + selectNewAuthority() { + if (this.selectedImportType === ImportType.NewAuthority) { + this.selectedImportType = ImportType.None; + } else { + this.selectedImportType = ImportType.NewAuthority; + this.deselectAllLists(); + } + } + + /** + * Deselect every element from both entity and authority lists + */ + deselectAllLists() { + this.selectService.deselectAll(this.entityListId); + this.selectService.deselectAll(this.authorityListId); + } +} diff --git a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.ts b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.ts index e26abf94c1..201d50e511 100644 --- a/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.ts +++ b/src/app/shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/relationship.effects.ts @@ -128,7 +128,7 @@ export class RelationshipEffects { this.relationshipService.getRelationshipByItemsAndLabel(item1, item2, relationshipType).pipe( take(1), hasValueOperator(), - mergeMap((relationship: Relationship) => this.relationshipService.deleteRelationship(relationship.id)), + mergeMap((relationship: Relationship) => this.relationshipService.deleteRelationship(relationship.id, 'none')), take(1) ).subscribe(); } diff --git a/src/app/shared/form/builder/form-builder.service.spec.ts b/src/app/shared/form/builder/form-builder.service.spec.ts index ea0957f689..972abb68b5 100644 --- a/src/app/shared/form/builder/form-builder.service.spec.ts +++ b/src/app/shared/form/builder/form-builder.service.spec.ts @@ -276,7 +276,7 @@ describe('FormBuilderService test suite', () => { { fields: [ { - input: {type: 'lookup'}, + input: { type: 'lookup' }, label: 'Journal', mandatory: 'false', repeatable: false, @@ -291,7 +291,7 @@ describe('FormBuilderService test suite', () => { languageCodes: [] } as FormFieldModel, { - input: {type: 'onebox'}, + input: { type: 'onebox' }, label: 'Issue', mandatory: 'false', repeatable: false, @@ -304,7 +304,7 @@ describe('FormBuilderService test suite', () => { languageCodes: [] } as FormFieldModel, { - input: {type: 'name'}, + input: { type: 'name' }, label: 'Name', mandatory: 'false', repeatable: false, @@ -322,24 +322,24 @@ describe('FormBuilderService test suite', () => { fields: [ { hints: 'If the item has any identification numbers or codes associated with↵ it, please enter the types and the actual numbers or codes.', - input: {type: 'onebox'}, + input: { type: 'onebox' }, label: 'Identifiers', languageCodes: [], mandatory: 'false', repeatable: false, selectableMetadata: [ - {metadata: 'dc.identifier.issn', label: 'ISSN'}, - {metadata: 'dc.identifier.other', label: 'Other'}, - {metadata: 'dc.identifier.ismn', label: 'ISMN'}, - {metadata: 'dc.identifier.govdoc', label: 'Gov\'t Doc #'}, - {metadata: 'dc.identifier.uri', label: 'URI'}, - {metadata: 'dc.identifier.isbn', label: 'ISBN'}, - {metadata: 'dc.identifier.doi', label: 'DOI'}, - {metadata: 'dc.identifier.pmid', label: 'PubMed ID'}, - {metadata: 'dc.identifier.arxiv', label: 'arXiv'} + { metadata: 'dc.identifier.issn', label: 'ISSN' }, + { metadata: 'dc.identifier.other', label: 'Other' }, + { metadata: 'dc.identifier.ismn', label: 'ISMN' }, + { metadata: 'dc.identifier.govdoc', label: 'Gov\'t Doc #' }, + { metadata: 'dc.identifier.uri', label: 'URI' }, + { metadata: 'dc.identifier.isbn', label: 'ISBN' }, + { metadata: 'dc.identifier.doi', label: 'DOI' }, + { metadata: 'dc.identifier.pmid', label: 'PubMed ID' }, + { metadata: 'dc.identifier.arxiv', label: 'arXiv' } ] }, { - input: {type: 'onebox'}, + input: { type: 'onebox' }, label: 'Publisher', mandatory: 'false', repeatable: false, @@ -356,7 +356,7 @@ describe('FormBuilderService test suite', () => { { fields: [ { - input: {type: 'onebox'}, + input: { type: 'onebox' }, label: 'Conference', mandatory: 'false', repeatable: false, @@ -373,10 +373,14 @@ describe('FormBuilderService test suite', () => { ] } as FormRowModel ], - self: 'testFormConfiguration.url', + self: { + href: 'testFormConfiguration.url' + }, type: 'submissionform', _links: { - self: 'testFormConfiguration.url' + self: { + href: 'testFormConfiguration.url' + } } } as any; }); diff --git a/src/app/shared/form/builder/models/form-field-previous-value-object.ts b/src/app/shared/form/builder/models/form-field-previous-value-object.ts index f0ead99f91..ca4a47c089 100644 --- a/src/app/shared/form/builder/models/form-field-previous-value-object.ts +++ b/src/app/shared/form/builder/models/form-field-previous-value-object.ts @@ -14,7 +14,7 @@ export class FormFieldPreviousValueObject { return this._path; } - set path(path: any[]) { + set path(path: string | string[]) { this._path = path; } diff --git a/src/app/shared/form/builder/parsers/field-parser.ts b/src/app/shared/form/builder/parsers/field-parser.ts index f7bf12353c..f218d442e1 100644 --- a/src/app/shared/form/builder/parsers/field-parser.ts +++ b/src/app/shared/form/builder/parsers/field-parser.ts @@ -49,7 +49,7 @@ export abstract class FieldParser { label: this.configData.label, initialCount: this.getInitArrayIndex(), notRepeatable: !this.configData.repeatable, - required: isNotEmpty(this.configData.mandatory), + required: JSON.parse( this.configData.mandatory), groupFactory: () => { let model; if ((arrayCounter === 0)) { diff --git a/src/app/shared/form/form.component.html b/src/app/shared/form/form.component.html index 510bf7291b..24948680c7 100644 --- a/src/app/shared/form/form.component.html +++ b/src/app/shared/form/form.component.html @@ -50,9 +50,9 @@
- +
diff --git a/src/app/shared/form/form.component.spec.ts b/src/app/shared/form/form.component.spec.ts index 3342db37ae..f0617c5c0a 100644 --- a/src/app/shared/form/form.component.spec.ts +++ b/src/app/shared/form/form.component.spec.ts @@ -142,7 +142,7 @@ describe('FormComponent test suite', () => { CommonModule, FormsModule, ReactiveFormsModule, - NgbModule.forRoot(), + NgbModule, StoreModule.forRoot({}), TranslateModule.forRoot() ], diff --git a/src/app/shared/form/form.component.ts b/src/app/shared/form/form.component.ts index 077def0060..def61cb5b2 100644 --- a/src/app/shared/form/form.component.ts +++ b/src/app/shared/form/form.component.ts @@ -53,6 +53,16 @@ export class FormComponent implements OnDestroy, OnInit { */ @Input() formId: string; + /** + * i18n key for the submit button + */ + @Input() submitLabel = 'form.submit'; + + /** + * i18n key for the cancel button + */ + @Input() cancelLabel = 'form.cancel'; + /** * An array of DynamicFormControlModel type */ diff --git a/src/app/shared/input-suggestions/input-suggestions.component.ts b/src/app/shared/input-suggestions/input-suggestions.component.ts index 7f8d0741de..ad052672c8 100644 --- a/src/app/shared/input-suggestions/input-suggestions.component.ts +++ b/src/app/shared/input-suggestions/input-suggestions.component.ts @@ -92,7 +92,7 @@ export class InputSuggestionsComponent implements ControlValueAccessor, OnChange /** * Reference to the input field component */ - @ViewChild('inputField') queryInput: ElementRef; + @ViewChild('inputField', {static: false}) queryInput: ElementRef; /** * Reference to the suggestion components */ 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/loading/loading.component.scss b/src/app/shared/loading/loading.component.scss index c9ccb5b2fa..e2287cdc8b 100644 --- a/src/app/shared/loading/loading.component.scss +++ b/src/app/shared/loading/loading.component.scss @@ -58,10 +58,10 @@ span.l-10 {-webkit-animation-delay: 0s;animation-delay: 0s;-ms-animation-delay: 100% {opacity: 0;} } -@-keyframes loader { - 0% {-transform: translateX(-30px); opacity: 0;} +@keyframes loader { + 0% {transform: translateX(-30px); opacity: 0;} 25% {opacity: 1;} - 50% {-transform: translateX(30px); opacity: 0;} + 50% {transform: translateX(30px); opacity: 0;} 100% {opacity: 0;} } @@ -70,4 +70,4 @@ span.l-10 {-webkit-animation-delay: 0s;animation-delay: 0s;-ms-animation-delay: 25% {opacity: 1;} 50% {-ms-transform: translateX(30px); opacity: 0;} 100% {opacity: 0;} -} \ No newline at end of file +} 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/menu/menu.actions.ts b/src/app/shared/menu/menu.actions.ts index 0c1533ed3b..00275441d6 100644 --- a/src/app/shared/menu/menu.actions.ts +++ b/src/app/shared/menu/menu.actions.ts @@ -223,4 +223,6 @@ export type MenuAction = | ActivateMenuSectionAction | DeactivateMenuSectionAction | ToggleActiveMenuSectionAction + | CollapseMenuPreviewAction + | ExpandMenuPreviewAction /* tslint:enable:max-classes-per-file */ diff --git a/src/app/shared/metadata-representation/metadata-representation-loader.component.ts b/src/app/shared/metadata-representation/metadata-representation-loader.component.ts index eb385b5afd..86bede6789 100644 --- a/src/app/shared/metadata-representation/metadata-representation-loader.component.ts +++ b/src/app/shared/metadata-representation/metadata-representation-loader.component.ts @@ -28,7 +28,7 @@ export class MetadataRepresentationLoaderComponent implements OnInit { /** * Directive to determine where the dynamic child component is located */ - @ViewChild(MetadataRepresentationDirective) mdRepDirective: MetadataRepresentationDirective; + @ViewChild(MetadataRepresentationDirective, {static: true}) mdRepDirective: MetadataRepresentationDirective; constructor(private componentFactoryResolver: ComponentFactoryResolver) { } diff --git a/src/app/shared/mocks/mock-item.ts b/src/app/shared/mocks/mock-item.ts index 3b77b630c5..a5b6a45d4a 100644 --- a/src/app/shared/mocks/mock-item.ts +++ b/src/app/shared/mocks/mock-item.ts @@ -1,11 +1,86 @@ -import {of as observableOf, Observable } from 'rxjs'; +import { of as observableOf } from 'rxjs'; +import { BitstreamFormat } from '../../core/shared/bitstream-format.model'; +import { Bitstream } from '../../core/shared/bitstream.model'; import { Item } from '../../core/shared/item.model'; -import { RemoteData } from '../../core/data/remote-data'; -import { Bitstream } from '../../core/shared/bitstream.model'; -import { PaginatedList } from '../../core/data/paginated-list'; import { createPaginatedList, createSuccessfulRemoteDataObject$ } from '../testing/utils'; +export const MockBitstreamFormat1: BitstreamFormat = Object.assign(new BitstreamFormat(), { + shortDescription: 'Microsoft Word XML', + description: 'Microsoft Word XML', + mimetype: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + supportLevel: 0, + internal: false, + extensions: null, + _links:{ + self: { + href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreamformats/10' + } + } +}); + +export const MockBitstreamFormat2: BitstreamFormat = Object.assign(new BitstreamFormat(), { + shortDescription: 'Adobe PDF', + description: 'Adobe Portable Document Format', + mimetype: 'application/pdf', + supportLevel: 0, + internal: false, + extensions: null, + _links:{ + self: { + href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreamformats/4' + } + } +}); + +export const MockBitstream1: Bitstream = Object.assign(new Bitstream(), + { + sizeBytes: 10201, + content: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', + format: observableOf(MockBitstreamFormat1), + bundleName: 'ORIGINAL', + _links:{ + self: { + href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713' + } + }, + id: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', + uuid: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', + type: 'bitstream', + metadata: { + 'dc.title': [ + { + language: null, + value: 'test_word.docx' + } + ] + } + }); + +export const MockBitstream2: Bitstream = Object.assign(new Bitstream(), { + sizeBytes: 31302, + content: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/content', + format: observableOf(MockBitstreamFormat2), + bundleName: 'ORIGINAL', + id: '99b00f3c-1cc6-4689-8158-91965bee6b28', + uuid: '99b00f3c-1cc6-4689-8158-91965bee6b28', + type: 'bitstream', + _links: { + self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28' }, + content: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/content' }, + format: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreamformats/4' }, + bundle: { href: '' } + }, + metadata: { + 'dc.title': [ + { + language: null, + value: 'test_pdf.pdf' + } + ] + } +}); + /* tslint:disable:no-shadowed-variable */ export const MockItem: Item = Object.assign(new Item(), { handle: '10673/6', @@ -17,7 +92,11 @@ export const MockItem: Item = Object.assign(new Item(), { { name: 'ORIGINAL', bitstreams: observableOf(Object.assign({ - self: 'dspace-angular://aggregated/object/1507836003548', + _links: { + self: { + href: 'dspace-angular://aggregated/object/1507836003548', + } + }, requestPending: false, responsePending: false, isSuccessful: true, @@ -39,82 +118,18 @@ export const MockItem: Item = Object.assign(new Item(), { currentPage: 2 }, page: [ - { - sizeBytes: 10201, - content: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/content', - format: observableOf({ - self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreamformats/10', - requestPending: false, - responsePending: false, - isSuccessful: true, - errorMessage: '', - statusCode: '202', - pageInfo: {}, - payload: { - shortDescription: 'Microsoft Word XML', - description: 'Microsoft Word XML', - mimetype: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - supportLevel: 0, - internal: false, - extensions: null, - self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreamformats/10' - } - }), - bundleName: 'ORIGINAL', - self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713', - id: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', - uuid: 'cf9b0c8e-a1eb-4b65-afd0-567366448713', - type: 'bitstream', - metadata: { - 'dc.title': [ - { - language: null, - value: 'test_word.docx' - } - ] - } - }, - { - sizeBytes: 31302, - content: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/content', - format: observableOf({ - self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreamformats/4', - requestPending: false, - responsePending: false, - isSuccessful: true, - errorMessage: '', - statusCode: '202', - pageInfo: {}, - payload: { - shortDescription: 'Adobe PDF', - description: 'Adobe Portable Document Format', - mimetype: 'application/pdf', - supportLevel: 0, - internal: false, - extensions: null, - self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreamformats/4' - } - }), - bundleName: 'ORIGINAL', - self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28', - id: '99b00f3c-1cc6-4689-8158-91965bee6b28', - uuid: '99b00f3c-1cc6-4689-8158-91965bee6b28', - type: 'bitstream', - metadata: { - 'dc.title': [ - { - language: null, - value: 'test_pdf.pdf' - } - ] - } - } + MockBitstream1, + MockBitstream2 ] } })) } ])), - self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357', + _links:{ + self: { + href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357' + } + }, id: '0ec7ff22-f211-40ab-a69e-c819b0b1f357', uuid: '0ec7ff22-f211-40ab-a69e-c819b0b1f357', type: 'item', @@ -219,7 +234,11 @@ export const MockItem: Item = Object.assign(new Item(), { ] }, owningCollection: observableOf({ - self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb', + _links: { + self: { + href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb' + } + }, requestPending: false, responsePending: false, isSuccessful: true, @@ -228,5 +247,6 @@ export const MockItem: Item = Object.assign(new Item(), { pageInfo: {}, payload: [] } - )}); + ) +}); /* tslint:enable:no-shadowed-variable */ diff --git a/src/app/shared/mocks/mock-link-service.ts b/src/app/shared/mocks/mock-link-service.ts new file mode 100644 index 0000000000..d50640a629 --- /dev/null +++ b/src/app/shared/mocks/mock-link-service.ts @@ -0,0 +1,9 @@ +import { LinkService } from '../../core/cache/builders/link.service'; + +export function getMockLinkService(): LinkService { + return jasmine.createSpyObj('linkService', { + resolveLinks: jasmine.createSpy('resolveLinks'), + resolveLink: jasmine.createSpy('resolveLink'), + removeResolvedLinks: jasmine.createSpy('removeResolvedLinks') + }); +} 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-remote-data-build.service.ts b/src/app/shared/mocks/mock-remote-data-build.service.ts index 2e492daf14..2dff033a26 100644 --- a/src/app/shared/mocks/mock-remote-data-build.service.ts +++ b/src/app/shared/mocks/mock-remote-data-build.service.ts @@ -1,13 +1,12 @@ -import { Observable, of as observableOf } from 'rxjs'; +import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { RemoteDataBuildService } from '../../core/cache/builders/remote-data-build.service'; +import { PaginatedList } from '../../core/data/paginated-list'; import { RemoteData } from '../../core/data/remote-data'; import { RequestEntry } from '../../core/data/request.reducer'; -import { hasValue } from '../empty.util'; -import { NormalizedObject } from '../../core/cache/models/normalized-object.model'; -import { createSuccessfulRemoteDataObject$ } from '../testing/utils'; -import { PaginatedList } from '../../core/data/paginated-list'; import { PageInfo } from '../../core/shared/page-info.model'; +import { hasValue } from '../empty.util'; +import { createSuccessfulRemoteDataObject$ } from '../testing/utils'; export function getMockRemoteDataBuildService(toRemoteDataObservable$?: Observable>, buildList$?: Observable>>): RemoteDataBuildService { return { @@ -22,7 +21,6 @@ export function getMockRemoteDataBuildService(toRemoteDataObservable$?: Observab } }, buildSingle: (href$: string | Observable) => createSuccessfulRemoteDataObject$({}), - build: (normalized: NormalizedObject) => Object.create({}), buildList: (href$: string | Observable) => { if (hasValue(buildList$)) { return buildList$; @@ -33,3 +31,38 @@ export function getMockRemoteDataBuildService(toRemoteDataObservable$?: Observab } as RemoteDataBuildService; } + +export function getMockRemoteDataBuildServiceHrefMap(toRemoteDataObservable$?: Observable>, buildListHrefMap$?: { [href: string]: Observable>>; }): RemoteDataBuildService { + return { + toRemoteDataObservable: (requestEntry$: Observable, payload$: Observable) => { + + if (hasValue(toRemoteDataObservable$)) { + return toRemoteDataObservable$; + } else { + return payload$.pipe(map((payload) => ({ + payload + } as RemoteData))) + } + }, + buildSingle: (href$: string | Observable) => createSuccessfulRemoteDataObject$({}), + buildList: (href$: string | Observable) => { + if (typeof href$ === 'string') { + if (hasValue(buildListHrefMap$[href$])) { + return buildListHrefMap$[href$]; + } else { + return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])) + } + } + href$.pipe( + map((href: string) => { + if (hasValue(buildListHrefMap$[href])) { + return buildListHrefMap$[href]; + } else { + return createSuccessfulRemoteDataObject$(new PaginatedList(new PageInfo(), [])) + } + }) + ); + } + } as RemoteDataBuildService; + +} diff --git a/src/app/shared/mocks/mock-request.service.ts b/src/app/shared/mocks/mock-request.service.ts index 103ab14d88..da297f56ac 100644 --- a/src/app/shared/mocks/mock-request.service.ts +++ b/src/app/shared/mocks/mock-request.service.ts @@ -10,9 +10,8 @@ export function getMockRequestService(requestEntry$: Observable = getByHref: requestEntry$, getByUUID: requestEntry$, uriEncodeBody: jasmine.createSpy('uriEncodeBody'), - hasByHrefObservable: observableOf(false), - /* tslint:disable:no-empty */ - removeByHrefSubstring: () => {} - /* tslint:enable:no-empty */ + isCachedOrPending: false, + removeByHrefSubstring: jasmine.createSpy('removeByHrefSubstring'), + hasByHrefObservable: observableOf(false) }); } diff --git a/src/app/shared/mocks/mock-submission.ts b/src/app/shared/mocks/mock-submission.ts index 922e6ad02d..082eec4c71 100644 --- a/src/app/shared/mocks/mock-submission.ts +++ b/src/app/shared/mocks/mock-submission.ts @@ -1,15 +1,16 @@ -import { SubmissionObjectState } from '../../submission/objects/submission-objects.reducer'; import { SubmissionDefinitionsModel } from '../../core/config/models/config-submission-definitions.model'; import { PaginatedList } from '../../core/data/paginated-list'; -import { PageInfo } from '../../core/shared/page-info.model'; -import { FormFieldMetadataValueObject } from '../form/builder/models/form-field-metadata-value.model'; import { Group } from '../../core/eperson/models/group.model'; +import { PageInfo } from '../../core/shared/page-info.model'; +import { SubmissionObjectState } from '../../submission/objects/submission-objects.reducer'; +import { FormFieldMetadataValueObject } from '../form/builder/models/form-field-metadata-value.model'; export const mockSectionsData = { - traditionalpageone:{ + traditionalpageone: { 'dc.title': [ - new FormFieldMetadataValueObject('test', null, null, 'test' ) - ]}, + new FormFieldMetadataValueObject('test', null, null, 'test') + ] + }, license: { url: null, acceptanceDate: null, @@ -21,14 +22,16 @@ export const mockSectionsData = { }; export const mockSectionsDataTwo = { - traditionalpageone:{ + traditionalpageone: { 'dc.title': [ - new FormFieldMetadataValueObject('test', null, null, 'test' ) - ]}, - traditionalpagetwo:{ + new FormFieldMetadataValueObject('test', null, null, 'test') + ] + }, + traditionalpagetwo: { 'dc.relation': [ - new FormFieldMetadataValueObject('test', null, null, 'test' ) - ]}, + new FormFieldMetadataValueObject('test', null, null, 'test') + ] + }, license: { url: null, acceptanceDate: null, @@ -68,14 +71,14 @@ export const mockUploadResponse1Errors = { ] }; -export const mockUploadResponse1ParsedErrors: any = { +export const mockUploadResponse1ParsedErrors: any = { traditionalpageone: [ { path: '/sections/traditionalpageone/dc.title', message: 'error.validation.required' }, { path: '/sections/traditionalpageone/dc.date.issued', message: 'error.validation.required' } ] }; -export const mockLicenseParsedErrors: any = { +export const mockLicenseParsedErrors: any = { license: [ { path: '/sections/license', message: 'error.validation.license.notgranted' } ] @@ -124,20 +127,18 @@ export const mockSubmissionRestResponse = [ content: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/content', format: [], bundleName: null, - self: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425', id: '3f859425-ffbd-4b0e-bf91-bfeb458a7425', uuid: '3f859425-ffbd-4b0e-bf91-bfeb458a7425', type: 'bitstream', name: null, metadata: [], _links: { - content: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/content', - format: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/format', - self: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425' + content: { href: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/content' }, + format: { href: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/format' }, + self: { href: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425' } } } ], - self: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb', id: '1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb', uuid: '1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb', type: 'collection', @@ -180,10 +181,10 @@ export const mockSubmissionRestResponse = [ } ], _links: { - license: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb/license', - defaultAccessConditions: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb/defaultAccessConditions', - logo: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb/logo', - self: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb' + license: { href: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb/license' }, + defaultAccessConditions: { href: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb/defaultAccessConditions' }, + logo: { href: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb/logo' }, + self: { href: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb' } } } ], @@ -195,17 +196,16 @@ export const mockSubmissionRestResponse = [ isDiscoverable: true, isWithdrawn: false, bitstreams: 'https://rest.api/dspace-spring-rest/api/core/items/6f344222-6980-4738-8192-b808d79af8a5/bitstreams', - self: 'https://rest.api/dspace-spring-rest/api/core/items/6f344222-6980-4738-8192-b808d79af8a5', id: '6f344222-6980-4738-8192-b808d79af8a5', uuid: '6f344222-6980-4738-8192-b808d79af8a5', type: 'item', name: null, metadata: [], _links: { - bitstreams: 'https://rest.api/dspace-spring-rest/api/core/items/6f344222-6980-4738-8192-b808d79af8a5/bitstreams', - owningCollection: 'https://rest.api/dspace-spring-rest/api/core/items/6f344222-6980-4738-8192-b808d79af8a5/owningCollection', - templateItemOf: 'https://rest.api/dspace-spring-rest/api/core/items/6f344222-6980-4738-8192-b808d79af8a5/templateItemOf', - self: 'https://rest.api/dspace-spring-rest/api/core/items/6f344222-6980-4738-8192-b808d79af8a5' + bitstreams: { href: 'https://rest.api/dspace-spring-rest/api/core/items/6f344222-6980-4738-8192-b808d79af8a5/bitstreams' }, + owningCollection: { href: 'https://rest.api/dspace-spring-rest/api/core/items/6f344222-6980-4738-8192-b808d79af8a5/owningCollection' }, + templateItemOf: { href: 'https://rest.api/dspace-spring-rest/api/core/items/6f344222-6980-4738-8192-b808d79af8a5/templateItemOf' }, + self: { href: 'https://rest.api/dspace-spring-rest/api/core/items/6f344222-6980-4738-8192-b808d79af8a5' } } } ], @@ -223,9 +223,9 @@ export const mockSubmissionRestResponse = [ }, type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/extraction' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/extraction' }, + config: '' }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/extraction' }, { mandatory: true, @@ -236,9 +236,9 @@ export const mockSubmissionRestResponse = [ }, type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/collection' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/collection' }, + config: '' }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/collection' }, { header: 'submit.progressbar.describe.stepone', @@ -246,10 +246,9 @@ export const mockSubmissionRestResponse = [ sectionType: 'submission-form', type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpageone', - config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpageone' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpageone' }, + config: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpageone' } }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpageone' }, { header: 'submit.progressbar.describe.steptwo', @@ -257,10 +256,9 @@ export const mockSubmissionRestResponse = [ sectionType: 'submission-form', type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpagetwo', - config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpagetwo' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpagetwo' }, + config: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpagetwo' } }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpagetwo' }, { header: 'submit.progressbar.upload', @@ -268,10 +266,9 @@ export const mockSubmissionRestResponse = [ sectionType: 'upload', type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/upload', - config: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/upload' }, + config: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload' } }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/upload' }, { header: 'submit.progressbar.license', @@ -283,31 +280,29 @@ export const mockSubmissionRestResponse = [ }, type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/license' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/license' }, + config: '' }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/license' } ], name: 'traditional', type: 'submissiondefinition', _links: { - collections: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/collections', - sections: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/sections', - self: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional' + collections: { href: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/collections' }, + sections: { href: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/sections' }, + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional' } }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional' } ], submitter: [], errors: [], - self: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826', type: 'workspaceitem', _links: { - collection: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/collection', - item: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/item', - submissionDefinition: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/submissionDefinition', - submitter: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/submitter', - self: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826' + collection: { href: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/collection' }, + item: { href: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/item' }, + submissionDefinition: { href: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/submissionDefinition' }, + submitter: { href: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/submitter' }, + self: { href: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826' } } } ]; @@ -329,10 +324,9 @@ export const mockSubmissionObject = { groupUUID: '11cc35e5-a11d-4b64-b5b9-0052a5d15509', id: 20, uuid: 'resource-policy-20', - self: 'https://rest.api/dspace-spring-rest/api/authz/resourcePolicies/20', type: 'resourcePolicy', _links: { - self: 'https://rest.api/dspace-spring-rest/api/authz/resourcePolicies/20' + self: { href: 'https://rest.api/dspace-spring-rest/api/authz/resourcePolicies/20' } } } ] @@ -342,19 +336,17 @@ export const mockSubmissionObject = { content: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/content', format: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/format', bundleName: null, - self: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425', id: '3f859425-ffbd-4b0e-bf91-bfeb458a7425', uuid: '3f859425-ffbd-4b0e-bf91-bfeb458a7425', type: 'bitstream', name: null, metadata: [], _links: { - content: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/content', - format: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/format', - self: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425' + content: { href: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/content' }, + format: { href: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/format' }, + self: { href: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425' } } }, - self: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb', id: '1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb', uuid: '1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb', type: 'collection', @@ -397,10 +389,10 @@ export const mockSubmissionObject = { } ], _links: { - license: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb/license', - defaultAccessConditions: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb/defaultAccessConditions', - logo: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb/logo', - self: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb' + license: { href: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb/license' }, + defaultAccessConditions: { href: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb/defaultAccessConditions' }, + logo: { href: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb/logo' }, + self: { href: 'https://rest.api/dspace-spring-rest/api/core/collections/1c11f3f1-ba1f-4f36-908a-3f1ea9a557eb' } } }, item: { @@ -419,17 +411,16 @@ export const mockSubmissionObject = { }, page: [] }, - self: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270', id: 'cae8af78-c874-4468-af79-e6c996aa8270', uuid: 'cae8af78-c874-4468-af79-e6c996aa8270', type: 'item', name: null, metadata: [], _links: { - bitstreams: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270/bitstreams', - owningCollection: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270/owningCollection', - templateItemOf: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270/templateItemOf', - self: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270' + bitstreams: { href: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270/bitstreams' }, + owningCollection: { href: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270/owningCollection' }, + templateItemOf: { href: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270/templateItemOf' }, + self: { href: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270' } } }, submissionDefinition: { @@ -451,9 +442,9 @@ export const mockSubmissionObject = { }, type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/collection' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/collection' }, + config: '' }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/collection' }, { header: 'submit.progressbar.describe.stepone', @@ -461,10 +452,9 @@ export const mockSubmissionObject = { sectionType: 'submission-form', type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpageone', - config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpageone' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpageone' }, + config: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpageone' } }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpageone' }, { header: 'submit.progressbar.describe.steptwo', @@ -472,10 +462,9 @@ export const mockSubmissionObject = { sectionType: 'submission-form', type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpagetwo', - config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpagetwo' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpagetwo' }, + config: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpagetwo' } }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpagetwo' }, { header: 'submit.progressbar.upload', @@ -483,10 +472,9 @@ export const mockSubmissionObject = { sectionType: 'upload', type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/upload', - config: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/upload' }, + config: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload' } }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/upload' }, { header: 'submit.progressbar.license', @@ -498,20 +486,19 @@ export const mockSubmissionObject = { }, type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/license' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/license' }, + config: '' }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/license' } ] }, name: 'traditional', type: 'submissiondefinition', _links: { - collections: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/collections', - sections: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/sections', - self: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional' + collections: { href: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/collections' }, + sections: { href: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/sections' }, + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional' } }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional', collections: { pageInfo: { elementsPerPage: 0, @@ -531,7 +518,6 @@ export const mockSubmissionObject = { email: 'dspacedemo+submit@gmail.com', requireCertificate: false, selfRegistered: false, - self: 'https://rest.api/dspace-spring-rest/api/eperson/epersons/99423c27-b642-5tg6-a9cd-6d910e68dca5', id: '99423c27-b642-5tg6-a9cd-6d910e68dca5', uuid: '99423c27-b642-5tg6-a9cd-6d910e68dca5', type: 'eperson', @@ -549,7 +535,7 @@ export const mockSubmissionObject = { } ], _links: { - self: 'https://rest.api/dspace-spring-rest/api/eperson/epersons/99423c27-b642-5tg6-a9cd-6d910e68dca5' + self: { href: 'https://rest.api/dspace-spring-rest/api/eperson/epersons/99423c27-b642-5tg6-a9cd-6d910e68dca5' } } }, id: 826, @@ -573,14 +559,13 @@ export const mockSubmissionObject = { ] } ], - self: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826', type: 'workspaceitem', _links: { - collection: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/collection', - item: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/item', - submissionDefinition: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/submissionDefinition', - submitter: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/submitter', - self: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826' + collection: { href: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/collection' }, + item: { href: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/item' }, + submissionDefinition: { href: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/submissionDefinition' }, + submitter: { href: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/submitter' }, + self: { href: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826' } } }; @@ -601,10 +586,9 @@ export const mockSubmissionObjectNew = { groupUUID: '11cc35e5-a11d-4b64-b5b9-0052a5d15509', id: 20, uuid: 'resource-policy-20', - self: 'https://rest.api/dspace-spring-rest/api/authz/resourcePolicies/20', type: 'resourcePolicy', _links: { - self: 'https://rest.api/dspace-spring-rest/api/authz/resourcePolicies/20' + self: { href: 'https://rest.api/dspace-spring-rest/api/authz/resourcePolicies/20' } } } ] @@ -614,19 +598,17 @@ export const mockSubmissionObjectNew = { content: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/content', format: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/format', bundleName: null, - self: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425', id: '3f859425-ffbd-4b0e-bf91-bfeb458a7425', uuid: '3f859425-ffbd-4b0e-bf91-bfeb458a7425', type: 'bitstream', name: null, metadata: [], _links: { - content: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/content', - format: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/format', - self: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425' + content: { href: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/content' }, + format: { href: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425/format' }, + self: { href: 'https://rest.api/dspace-spring-rest/api/core/bitstreams/3f859425-ffbd-4b0e-bf91-bfeb458a7425' } } }, - self: 'https://rest.api/dspace-spring-rest/api/core/collections/45f2f3f1-ba1f-4f36-908a-3f1ea9a557eb', id: '45f2f3f1-ba1f-4f36-908a-3f1ea9a557eb', uuid: '45f2f3f1-ba1f-4f36-908a-3f1ea9a557eb', type: 'collection', @@ -669,10 +651,10 @@ export const mockSubmissionObjectNew = { } ], _links: { - license: 'https://rest.api/dspace-spring-rest/api/core/collections/45f2f3f1-ba1f-4f36-908a-3f1ea9a557eb/license', - defaultAccessConditions: 'https://rest.api/dspace-spring-rest/api/core/collections/45f2f3f1-ba1f-4f36-908a-3f1ea9a557eb/defaultAccessConditions', - logo: 'https://rest.api/dspace-spring-rest/api/core/collections/45f2f3f1-ba1f-4f36-908a-3f1ea9a557eb/logo', - self: 'https://rest.api/dspace-spring-rest/api/core/collections/45f2f3f1-ba1f-4f36-908a-3f1ea9a557eb' + license: { href: 'https://rest.api/dspace-spring-rest/api/core/collections/45f2f3f1-ba1f-4f36-908a-3f1ea9a557eb/license' }, + defaultAccessConditions: { href: 'https://rest.api/dspace-spring-rest/api/core/collections/45f2f3f1-ba1f-4f36-908a-3f1ea9a557eb/defaultAccessConditions' }, + logo: { href: 'https://rest.api/dspace-spring-rest/api/core/collections/45f2f3f1-ba1f-4f36-908a-3f1ea9a557eb/logo' }, + self: { href: 'https://rest.api/dspace-spring-rest/api/core/collections/45f2f3f1-ba1f-4f36-908a-3f1ea9a557eb' } } }, item: { @@ -691,17 +673,16 @@ export const mockSubmissionObjectNew = { }, page: [] }, - self: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270', id: 'cae8af78-c874-4468-af79-e6c996aa8270', uuid: 'cae8af78-c874-4468-af79-e6c996aa8270', type: 'item', name: null, metadata: [], _links: { - bitstreams: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270/bitstreams', - owningCollection: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270/owningCollection', - templateItemOf: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270/templateItemOf', - self: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270' + bitstreams: { href: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270/bitstreams' }, + owningCollection: { href: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270/owningCollection' }, + templateItemOf: { href: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270/templateItemOf' }, + self: { href: 'https://rest.api/dspace-spring-rest/api/core/items/cae8af78-c874-4468-af79-e6c996aa8270' } } }, submissionDefinition: { @@ -723,9 +704,9 @@ export const mockSubmissionObjectNew = { }, type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/collection' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/collection' }, + config: '' }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/collection' }, { header: 'submit.progressbar.describe.stepone', @@ -733,10 +714,9 @@ export const mockSubmissionObjectNew = { sectionType: 'submission-form', type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpageone', - config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpageone' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpageone' }, + config: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpageone' } }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpageone' }, { header: 'submit.progressbar.describe.steptwo', @@ -744,10 +724,9 @@ export const mockSubmissionObjectNew = { sectionType: 'submission-form', type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpagetwo', - config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpagetwo' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpagetwo' }, + config: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpagetwo' } }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpagetwo' }, { header: 'submit.progressbar.upload', @@ -755,10 +734,9 @@ export const mockSubmissionObjectNew = { sectionType: 'upload', type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/upload', - config: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/upload' }, + config: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload' } }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/upload' }, { header: 'submit.progressbar.license', @@ -770,20 +748,19 @@ export const mockSubmissionObjectNew = { }, type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/license' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/license' }, + config: '' }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/license' } ] }, name: 'traditionaltwo', type: 'submissiondefinition', _links: { - collections: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/collections', - sections: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/sections', - self: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional' + collections: { href: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/collections' }, + sections: { href: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/sections' }, + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional' } }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional', collections: { pageInfo: { elementsPerPage: 0, @@ -803,7 +780,6 @@ export const mockSubmissionObjectNew = { email: 'dspacedemo+submit@gmail.com', requireCertificate: false, selfRegistered: false, - self: 'https://rest.api/dspace-spring-rest/api/eperson/epersons/99423c27-b642-4bb9-a9cd-45gh23e68dca5', id: '99423c27-b642-4bb9-a9cd-45gh23e68dca5', uuid: '99423c27-b642-4bb9-a9cd-45gh23e68dca5', type: 'eperson', @@ -821,21 +797,20 @@ export const mockSubmissionObjectNew = { } ], _links: { - self: 'https://rest.api/dspace-spring-rest/api/eperson/epersons/99423c27-b642-4bb9-a9cd-45gh23e68dca5' + self: { href: 'https://rest.api/dspace-spring-rest/api/eperson/epersons/99423c27-b642-4bb9-a9cd-45gh23e68dca5' } } }, id: 826, lastModified: '2019-01-09T10:17:33.738+0000', sections: {}, errors: [], - self: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826', type: 'workspaceitem', _links: { - collection: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/collection', - item: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/item', - submissionDefinition: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/submissionDefinition', - submitter: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/submitter', - self: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826' + collection: { href: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/collection' }, + item: { href: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/item' }, + submissionDefinition: { href: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/submissionDefinition' }, + submitter: { href: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826/submitter' }, + self: { href: 'https://rest.api/dspace-spring-rest/api/submission/workspaceitems/826' } } }; @@ -857,9 +832,9 @@ export const mockSubmissionDefinitionResponse = { }, type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/extraction' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/extraction' }, + config: '' }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/extraction' }, { mandatory: true, @@ -870,9 +845,9 @@ export const mockSubmissionDefinitionResponse = { }, type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/collection' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/collection' }, + config: '' }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/collection' }, { header: 'submit.progressbar.describe.stepone', @@ -880,10 +855,9 @@ export const mockSubmissionDefinitionResponse = { sectionType: 'submission-form', type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpageone', - config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpageone' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpageone' }, + config: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpageone' } }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpageone' }, { header: 'submit.progressbar.describe.steptwo', @@ -891,10 +865,9 @@ export const mockSubmissionDefinitionResponse = { sectionType: 'submission-form', type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpagetwo', - config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpagetwo' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpagetwo' }, + config: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpagetwo' } }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpagetwo' }, { header: 'submit.progressbar.upload', @@ -902,10 +875,9 @@ export const mockSubmissionDefinitionResponse = { sectionType: 'upload', type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/upload', - config: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/upload' }, + config: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload' } }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/upload' }, { header: 'submit.progressbar.license', @@ -917,24 +889,23 @@ export const mockSubmissionDefinitionResponse = { }, type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/license' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/license' }, + config: '' }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/license' } ], name: 'traditional', type: 'submissiondefinition', _links: { - collections: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/collections', - sections: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/sections', - self: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional' + collections: { href: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/collections' }, + sections: { href: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/sections' }, + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional' } }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional' } as any; export const mockSubmissionDefinition: SubmissionDefinitionsModel = { isDefault: true, - sections: new PaginatedList(new PageInfo(),[ + sections: new PaginatedList(new PageInfo(), [ { mandatory: true, sectionType: 'utils', @@ -944,9 +915,9 @@ export const mockSubmissionDefinition: SubmissionDefinitionsModel = { }, type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/extraction' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/extraction' }, + config: '' }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/extraction' }, { mandatory: true, @@ -957,9 +928,9 @@ export const mockSubmissionDefinition: SubmissionDefinitionsModel = { }, type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/collection' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/collection' }, + config: '' }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/collection' }, { header: 'submit.progressbar.describe.stepone', @@ -967,10 +938,9 @@ export const mockSubmissionDefinition: SubmissionDefinitionsModel = { sectionType: 'submission-form', type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpageone', - config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpageone' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpageone' }, + config: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpageone' } }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpageone' }, { header: 'submit.progressbar.describe.steptwo', @@ -978,10 +948,9 @@ export const mockSubmissionDefinition: SubmissionDefinitionsModel = { sectionType: 'submission-form', type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpagetwo', - config: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpagetwo' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpagetwo' }, + config: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/traditionalpagetwo' } }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/traditionalpagetwo' }, { header: 'submit.progressbar.upload', @@ -989,10 +958,9 @@ export const mockSubmissionDefinition: SubmissionDefinitionsModel = { sectionType: 'upload', type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/upload', - config: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/upload' }, + config: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload' } }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/upload' }, { header: 'submit.progressbar.license', @@ -1004,19 +972,18 @@ export const mockSubmissionDefinition: SubmissionDefinitionsModel = { }, type: 'submissionsection', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/license' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/license' }, + config: '' }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionsections/license' } ]), name: 'traditional', type: 'submissiondefinition', _links: { - collections: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/collections', - sections: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/sections', - self: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional' + collections: { href: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/collections' }, + sections: { href: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional/sections' }, + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional' } }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissiondefinitions/traditional' } as any; export const mockSubmissionState: SubmissionObjectState = Object.assign({}, { @@ -1321,21 +1288,23 @@ export const mockUploadConfigResponse = { name: 'bitstream-metadata', type: 'submissionform', _links: { - self: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/bitstream-metadata' + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/bitstream-metadata' } }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionforms/bitstream-metadata' }, - required: false, + required: true, maxSize: 536870912, name: 'upload', type: 'submissionupload', _links: { - metadata: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload/metadata', - self: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload' + metadata: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload/metadata' }, + self: { href: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload' } }, - self: 'https://rest.api/dspace-spring-rest/api/config/submissionuploads/upload' }; +// Clone the object and change one property +export const mockUploadConfigResponseNotRequired = JSON.parse(JSON.stringify(mockUploadConfigResponse)); +mockUploadConfigResponseNotRequired.required = false; + export const mockAccessConditionOptions = [ { name: 'openaccess', @@ -1368,15 +1337,14 @@ export const mockAccessConditionOptions = [ export const mockGroup = Object.assign(new Group(), { handle: null, permanent: true, - self: 'https://rest.api/dspace-spring-rest/api/eperson/groups/123456-g1', id: '123456-g', uuid: '123456-g', type: 'group', name: 'Anonymous', metadata: [], _links: { - groups: 'https://rest.api/dspace-spring-rest/api/eperson/groups/123456-g1/groups', - self: 'https://rest.api/dspace-spring-rest/api/eperson/groups/123456-g1' + groups: { href: 'https://rest.api/dspace-spring-rest/api/eperson/groups/123456-g1/groups' }, + self: { href: 'https://rest.api/dspace-spring-rest/api/eperson/groups/123456-g1' } }, groups: { pageInfo: { 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/abstract/claimed-task-actions-abstract.component.ts b/src/app/shared/mydspace-actions/claimed-task/abstract/claimed-task-actions-abstract.component.ts new file mode 100644 index 0000000000..dafc148147 --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/abstract/claimed-task-actions-abstract.component.ts @@ -0,0 +1,61 @@ +import { EventEmitter, Input, Output } from '@angular/core'; +import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; +import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; +import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; +import { ProcessTaskResponse } from '../../../../core/tasks/models/process-task-response'; + +/** + * Abstract component for rendering a claimed task's action + * To create a child-component for a new option: + * - Set the "option" of the component + * - Add a @rendersWorkflowTaskOption annotation to your component providing the same enum value + * - Optionally overwrite createBody if the request body requires more than just the option + */ +export abstract class ClaimedTaskActionsAbstractComponent { + /** + * The workflow task option the child component represents + */ + abstract option: string; + + /** + * The Claimed Task to display an action for + */ + @Input() object: ClaimedTask; + + /** + * Emits the success or failure of a processed action + */ + @Output() processCompleted: EventEmitter = new EventEmitter(); + + /** + * A boolean representing if the operation is pending + */ + processing$ = new BehaviorSubject(false); + + constructor(protected claimedTaskService: ClaimedTaskDataService) { + } + + /** + * Create a request body for submitting the task + * Overwrite this method in the child component if the body requires more than just the option + */ + createbody(): any { + return { + [this.option]: 'true' + }; + } + + /** + * Submit the task for this option + * While the task is submitting, processing$ is set to true and processCompleted emits the response's status when + * completed + */ + submitTask() { + this.processing$.next(true); + this.claimedTaskService.submitTask(this.object.id, this.createbody()) + .subscribe((res: ProcessTaskResponse) => { + this.processing$.next(false); + this.processCompleted.emit(res.hasSucceeded); + }); + } +} diff --git a/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.html b/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.html index 3c41fdbb07..7944d24d96 100644 --- a/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.html +++ b/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.html @@ -1,8 +1,8 @@ diff --git a/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.spec.ts b/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.spec.ts index 552d31675e..1cbfdb7c46 100644 --- a/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.spec.ts +++ b/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.spec.ts @@ -2,14 +2,23 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; import { ClaimedTaskActionsApproveComponent } from './claimed-task-actions-approve.component'; import { MockTranslateLoader } from '../../../mocks/mock-translate-loader'; +import { ProcessTaskResponse } from '../../../../core/tasks/models/process-task-response'; +import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; +import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; let component: ClaimedTaskActionsApproveComponent; let fixture: ComponentFixture; describe('ClaimedTaskActionsApproveComponent', () => { + const object = Object.assign(new ClaimedTask(), { id: 'claimed-task-1' }); + const claimedTaskService = jasmine.createSpyObj('claimedTaskService', { + submitTask: observableOf(new ProcessTaskResponse(true)) + }); + beforeEach(async(() => { TestBed.configureTestingModule({ imports: [ @@ -20,6 +29,9 @@ describe('ClaimedTaskActionsApproveComponent', () => { } }) ], + providers: [ + { provide: ClaimedTaskDataService, useValue: claimedTaskService } + ], declarations: [ClaimedTaskActionsApproveComponent], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(ClaimedTaskActionsApproveComponent, { @@ -30,14 +42,10 @@ describe('ClaimedTaskActionsApproveComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(ClaimedTaskActionsApproveComponent); component = fixture.componentInstance; + component.object = object; fixture.detectChanges(); }); - afterEach(() => { - fixture = null; - component = null; - }); - it('should display approve button', () => { const btn = fixture.debugElement.query(By.css('.btn-success')); @@ -45,7 +53,7 @@ describe('ClaimedTaskActionsApproveComponent', () => { }); it('should display spin icon when approve is pending', () => { - component.processingApprove = true; + component.processing$.next(true); fixture.detectChanges(); const span = fixture.debugElement.query(By.css('.btn-success .fa-spin')); @@ -53,13 +61,27 @@ describe('ClaimedTaskActionsApproveComponent', () => { expect(span).toBeDefined(); }); - it('should emit approve event', () => { - spyOn(component.approve, 'emit'); + describe('submitTask', () => { + let expectedBody; - component.confirmApprove(); - fixture.detectChanges(); + beforeEach(() => { + spyOn(component.processCompleted, 'emit'); - expect(component.approve.emit).toHaveBeenCalled(); + expectedBody = { + [component.option]: 'true' + }; + + component.submitTask(); + fixture.detectChanges(); + }); + + it('should call claimedTaskService\'s submitTask with the expected body', () => { + expect(claimedTaskService.submitTask).toHaveBeenCalledWith(object.id, expectedBody) + }); + + it('should emit a successful processCompleted event', () => { + expect(component.processCompleted.emit).toHaveBeenCalledWith(true); + }); }); }); diff --git a/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.ts b/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.ts index 8e7c0dab60..8f51ac393c 100644 --- a/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.ts +++ b/src/app/shared/mydspace-actions/claimed-task/approve/claimed-task-actions-approve.component.ts @@ -1,32 +1,26 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Component } from '@angular/core'; +import { ClaimedTaskActionsAbstractComponent } from '../abstract/claimed-task-actions-abstract.component'; +import { rendersWorkflowTaskOption } from '../switcher/claimed-task-actions-decorator'; +import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; +export const WORKFLOW_TASK_OPTION_APPROVE = 'submit_approve'; + +@rendersWorkflowTaskOption(WORKFLOW_TASK_OPTION_APPROVE) @Component({ selector: 'ds-claimed-task-actions-approve', styleUrls: ['./claimed-task-actions-approve.component.scss'], templateUrl: './claimed-task-actions-approve.component.html', }) - -export class ClaimedTaskActionsApproveComponent { - +/** + * Component for displaying and processing the approve action on a workflow task item + */ +export class ClaimedTaskActionsApproveComponent extends ClaimedTaskActionsAbstractComponent { /** - * A boolean representing if a reject operation is pending + * This component represents the approve option */ - @Input() processingApprove: boolean; + option = WORKFLOW_TASK_OPTION_APPROVE; - /** - * CSS classes to append to reject button - */ - @Input() wrapperClass: string; - - /** - * An event fired when a approve action is confirmed. - */ - @Output() approve: EventEmitter = new EventEmitter(); - - /** - * Emit approve event - */ - confirmApprove() { - this.approve.emit(); + constructor(protected claimedTaskService: ClaimedTaskDataService) { + super(claimedTaskService); } } 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..aa569bbfc8 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 @@ -1,20 +1,13 @@ - - - {{'submission.workflow.tasks.claimed.edit' | translate}} - - - - - - - + +
+ + + + +
+
diff --git a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.spec.ts b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.spec.ts index 71991bdf25..f30feb4163 100644 --- a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.spec.ts +++ b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.spec.ts @@ -1,7 +1,6 @@ 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'; import { of as observableOf } from 'rxjs'; import { cold } from 'jasmine-marbles'; @@ -16,11 +15,14 @@ import { ClaimedTaskDataService } from '../../../core/tasks/claimed-task-data.se import { ClaimedTaskActionsComponent } from './claimed-task-actions.component'; import { ClaimedTask } from '../../../core/tasks/models/claimed-task-object.model'; import { WorkflowItem } from '../../../core/submission/models/workflowitem.model'; -import { createSuccessfulRemoteDataObject } from '../../testing/utils'; +import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../testing/utils'; import { getMockSearchService } from '../../mocks/mock-search-service'; import { getMockRequestService } from '../../mocks/mock-request.service'; import { RequestService } from '../../../core/data/request.service'; import { SearchService } from '../../../core/shared/search/search.service'; +import { WorkflowActionDataService } from '../../../core/data/workflow-action-data.service'; +import { WorkflowAction } from '../../../core/tasks/models/workflow-action-object.model'; +import { VarDirective } from '../../utils/var.directive'; let component: ClaimedTaskActionsComponent; let fixture: ComponentFixture; @@ -30,15 +32,15 @@ let notificationsServiceStub: NotificationsServiceStub; let router: RouterStub; let mockDataService; - let searchService; - let requestServce; +let workflowActionService: WorkflowActionDataService; let item; let rdItem; let workflowitem; let rdWorkflowitem; +let workflowAction; function init() { mockDataService = jasmine.createSpyObj('ClaimedTaskDataService', { @@ -46,9 +48,7 @@ function init() { rejectTask: jasmine.createSpy('rejectTask'), returnToPoolTask: jasmine.createSpy('returnToPoolTask'), }); - searchService = getMockSearchService(); - requestServce = getMockRequestService(); item = Object.assign(new Item(), { @@ -84,7 +84,11 @@ function init() { workflowitem = Object.assign(new WorkflowItem(), { item: observableOf(rdItem) }); rdWorkflowitem = createSuccessfulRemoteDataObject(workflowitem); mockObject = Object.assign(new ClaimedTask(), { workflowitem: observableOf(rdWorkflowitem), id: '1234' }); + workflowAction = Object.assign(new WorkflowAction(), { id: 'action-1', options: ['option-1', 'option-2'] }); + workflowActionService = jasmine.createSpyObj('workflowActionService', { + findById: createSuccessfulRemoteDataObject$(workflowAction) + }); } describe('ClaimedTaskActionsComponent', () => { @@ -99,14 +103,15 @@ describe('ClaimedTaskActionsComponent', () => { } }) ], - declarations: [ClaimedTaskActionsComponent], + declarations: [ClaimedTaskActionsComponent, VarDirective], providers: [ { provide: Injector, useValue: {} }, { provide: NotificationsService, useValue: new NotificationsServiceStub() }, { provide: Router, useValue: new RouterStub() }, { provide: ClaimedTaskDataService, useValue: mockDataService }, { provide: SearchService, useValue: searchService }, - { provide: RequestService, useValue: requestServce } + { provide: RequestService, useValue: requestServce }, + { provide: WorkflowActionDataService, useValue: workflowActionService } ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(ClaimedTaskActionsComponent, { @@ -123,11 +128,6 @@ describe('ClaimedTaskActionsComponent', () => { fixture.detectChanges(); }); - afterEach(() => { - fixture = null; - component = null; - }); - it('should init objects properly', () => { component.object = null; component.initObjects(mockObject); @@ -136,46 +136,14 @@ describe('ClaimedTaskActionsComponent', () => { expect(component.workflowitem$).toBeObservable(cold('(b|)', { b: rdWorkflowitem.payload - })) + })); }); - it('should display edit task button', () => { - const btn = fixture.debugElement.query(By.css('.btn-info')); - - expect(btn).toBeDefined(); - }); - - it('should call approveTask method when approving a task', fakeAsync(() => { - spyOn(component, 'reload'); - mockDataService.approveTask.and.returnValue(observableOf({hasSucceeded: true})); - - component.approve(); - fixture.detectChanges(); - - fixture.whenStable().then(() => { - expect(mockDataService.approveTask).toHaveBeenCalledWith(mockObject.id); - }); - - })); - - it('should display a success notification on approve success', async(() => { - spyOn(component, 'reload'); - mockDataService.approveTask.and.returnValue(observableOf({hasSucceeded: true})); - - component.approve(); - fixture.detectChanges(); - - fixture.whenStable().then(() => { - expect(notificationsServiceStub.success).toHaveBeenCalled(); - }); - })); - - it('should reload page on approve success', async(() => { + it('should reload page on process completed', async(() => { spyOn(router, 'navigateByUrl'); router.url = 'test.url/test'; - mockDataService.approveTask.and.returnValue(observableOf({hasSucceeded: true})); - component.approve(); + component.handleActionResponse(true); fixture.detectChanges(); fixture.whenStable().then(() => { @@ -183,108 +151,8 @@ describe('ClaimedTaskActionsComponent', () => { }); })); - it('should display an error notification on approve failure', async(() => { - mockDataService.approveTask.and.returnValue(observableOf({hasSucceeded: false})); - - component.approve(); - fixture.detectChanges(); - - fixture.whenStable().then(() => { - expect(notificationsServiceStub.error).toHaveBeenCalled(); - }); - })); - - it('should call rejectTask method when rejecting a task', fakeAsync(() => { - spyOn(component, 'reload'); - mockDataService.rejectTask.and.returnValue(observableOf({hasSucceeded: true})); - - component.reject('test reject'); - fixture.detectChanges(); - - fixture.whenStable().then(() => { - expect(mockDataService.rejectTask).toHaveBeenCalledWith('test reject', mockObject.id); - }); - - })); - - it('should display a success notification on reject success', async(() => { - spyOn(component, 'reload'); - mockDataService.rejectTask.and.returnValue(observableOf({hasSucceeded: true})); - - component.reject('test reject'); - fixture.detectChanges(); - - fixture.whenStable().then(() => { - expect(notificationsServiceStub.success).toHaveBeenCalled(); - }); - })); - - it('should reload page on reject success', async(() => { - spyOn(router, 'navigateByUrl'); - router.url = 'test.url/test'; - mockDataService.rejectTask.and.returnValue(observableOf({hasSucceeded: true})); - - component.reject('test reject'); - fixture.detectChanges(); - - fixture.whenStable().then(() => { - expect(router.navigateByUrl).toHaveBeenCalledWith('test.url/test'); - }); - })); - - it('should display an error notification on reject failure', async(() => { - mockDataService.rejectTask.and.returnValue(observableOf({hasSucceeded: false})); - - component.reject('test reject'); - fixture.detectChanges(); - - fixture.whenStable().then(() => { - expect(notificationsServiceStub.error).toHaveBeenCalled(); - }); - })); - - it('should call returnToPoolTask method when returning a task to pool', fakeAsync(() => { - spyOn(component, 'reload'); - mockDataService.returnToPoolTask.and.returnValue(observableOf({hasSucceeded: true})); - - component.returnToPool(); - fixture.detectChanges(); - - fixture.whenStable().then(() => { - expect(mockDataService.returnToPoolTask).toHaveBeenCalledWith( mockObject.id); - }); - - })); - - it('should display a success notification on return to pool success', async(() => { - spyOn(component, 'reload'); - mockDataService.returnToPoolTask.and.returnValue(observableOf({hasSucceeded: true})); - - component.returnToPool(); - fixture.detectChanges(); - - fixture.whenStable().then(() => { - expect(notificationsServiceStub.success).toHaveBeenCalled(); - }); - })); - - it('should reload page on return to pool success', async(() => { - spyOn(router, 'navigateByUrl'); - router.url = 'test.url/test'; - mockDataService.returnToPoolTask.and.returnValue(observableOf({hasSucceeded: true})); - - component.returnToPool(); - fixture.detectChanges(); - - fixture.whenStable().then(() => { - expect(router.navigateByUrl).toHaveBeenCalledWith('test.url/test'); - }); - })); - - it('should display an error notification on return to pool failure', async(() => { - mockDataService.returnToPoolTask.and.returnValue(observableOf({hasSucceeded: false})); - - component.returnToPool(); + it('should display an error notification on process failure', async(() => { + component.handleActionResponse(false); fixture.detectChanges(); fixture.whenStable().then(() => { diff --git a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.ts b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.ts index 81d24fa1d7..c82154af09 100644 --- a/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.ts +++ b/src/app/shared/mydspace-actions/claimed-task/claimed-task-actions.component.ts @@ -1,13 +1,12 @@ import { Component, Injector, Input, OnInit } from '@angular/core'; import { Router } from '@angular/router'; -import { BehaviorSubject, Observable } from 'rxjs'; +import { Observable } from 'rxjs'; import { filter, map } from 'rxjs/operators'; import { TranslateService } from '@ngx-translate/core'; import { ClaimedTaskDataService } from '../../../core/tasks/claimed-task-data.service'; import { ClaimedTask } from '../../../core/tasks/models/claimed-task-object.model'; -import { ProcessTaskResponse } from '../../../core/tasks/models/process-task-response'; import { isNotUndefined } from '../../empty.util'; import { WorkflowItem } from '../../../core/submission/models/workflowitem.model'; import { RemoteData } from '../../../core/data/remote-data'; @@ -15,6 +14,9 @@ import { MyDSpaceActionsComponent } from '../mydspace-actions'; import { NotificationsService } from '../../notifications/notifications.service'; import { RequestService } from '../../../core/data/request.service'; import { SearchService } from '../../../core/shared/search/search.service'; +import { WorkflowAction } from '../../../core/tasks/models/workflow-action-object.model'; +import { WorkflowActionDataService } from '../../../core/data/workflow-action-data.service'; +import { WORKFLOW_TASK_OPTION_RETURN_TO_POOL } from './return-to-pool/claimed-task-actions-return-to-pool.component'; /** * This component represents actions related to ClaimedTask object. @@ -37,19 +39,15 @@ export class ClaimedTaskActionsComponent extends MyDSpaceActionsComponent; /** - * A boolean representing if an approve operation is pending + * The workflow action available for this task */ - public processingApprove$ = new BehaviorSubject(false); + public actionRD$: Observable>; /** - * A boolean representing if a reject operation is pending + * The option used to render the "return to pool" component + * Every claimed task contains this option */ - public processingReject$ = new BehaviorSubject(false); - - /** - * A boolean representing if a return to pool operation is pending - */ - public processingReturnToPool$ = new BehaviorSubject(false); + public returnToPoolOption = WORKFLOW_TASK_OPTION_RETURN_TO_POOL; /** * Initialize instance variables @@ -60,13 +58,15 @@ export class ClaimedTaskActionsComponent extends MyDSpaceActionsComponent { - this.processingApprove$.next(false); - this.handleActionResponse(res.hasSucceeded); - }); - } - - /** - * Reject the task. - */ - reject(reason) { - this.processingReject$.next(true); - this.objectDataService.rejectTask(reason, this.object.id) - .subscribe((res: ProcessTaskResponse) => { - this.processingReject$.next(false); - this.handleActionResponse(res.hasSucceeded); - }); - } - - /** - * Return task to the pool. - */ - returnToPool() { - this.processingReturnToPool$.next(true); - this.objectDataService.returnToPoolTask(this.object.id) - .subscribe((res: ProcessTaskResponse) => { - this.processingReturnToPool$.next(false); - this.handleActionResponse(res.hasSucceeded); - }); + initAction(object: ClaimedTask) { + this.actionRD$ = object.action; } } diff --git a/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.html b/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.html new file mode 100644 index 0000000000..4a42378f7e --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.html @@ -0,0 +1,7 @@ + + {{'submission.workflow.tasks.claimed.edit' | translate}} + diff --git a/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.scss b/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.spec.ts b/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.spec.ts new file mode 100644 index 0000000000..912671bd4b --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.spec.ts @@ -0,0 +1,50 @@ +import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; + +import { ClaimedTaskActionsEditMetadataComponent } from './claimed-task-actions-edit-metadata.component'; +import { MockTranslateLoader } from '../../../mocks/mock-translate-loader'; +import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; +import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; + +let component: ClaimedTaskActionsEditMetadataComponent; +let fixture: ComponentFixture; + +describe('ClaimedTaskActionsEditMetadataComponent', () => { + const object = Object.assign(new ClaimedTask(), { id: 'claimed-task-1' }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: MockTranslateLoader + } + }) + ], + providers: [ + { provide: ClaimedTaskDataService, useValue: {} } + ], + declarations: [ClaimedTaskActionsEditMetadataComponent], + schemas: [NO_ERRORS_SCHEMA] + }).overrideComponent(ClaimedTaskActionsEditMetadataComponent, { + set: { changeDetection: ChangeDetectionStrategy.Default } + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ClaimedTaskActionsEditMetadataComponent); + component = fixture.componentInstance; + component.object = object; + fixture.detectChanges(); + }); + + it('should display edit button', () => { + const btn = fixture.debugElement.query(By.css('.btn-primary')); + + expect(btn).toBeDefined(); + }); + +}); diff --git a/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.ts b/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.ts new file mode 100644 index 0000000000..c0ce9cd4e5 --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component.ts @@ -0,0 +1,26 @@ +import { Component } from '@angular/core'; +import { ClaimedTaskActionsAbstractComponent } from '../abstract/claimed-task-actions-abstract.component'; +import { rendersWorkflowTaskOption } from '../switcher/claimed-task-actions-decorator'; +import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; + +export const WORKFLOW_TASK_OPTION_EDIT_METADATA = 'submit_edit_metadata'; + +@rendersWorkflowTaskOption(WORKFLOW_TASK_OPTION_EDIT_METADATA) +@Component({ + selector: 'ds-claimed-task-actions-edit-metadata', + styleUrls: ['./claimed-task-actions-edit-metadata.component.scss'], + templateUrl: './claimed-task-actions-edit-metadata.component.html', +}) +/** + * Component for displaying the edit metadata action on a workflow task item + */ +export class ClaimedTaskActionsEditMetadataComponent extends ClaimedTaskActionsAbstractComponent { + /** + * This component represents the edit metadata option + */ + option = WORKFLOW_TASK_OPTION_EDIT_METADATA; + + constructor(protected claimedTaskService: ClaimedTaskDataService) { + super(claimedTaskService); + } +} diff --git a/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.html b/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.html index 91edee66bd..7c7b83cd8a 100644 --- a/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.html +++ b/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.html @@ -1,10 +1,10 @@

- @@ -21,17 +21,17 @@ -
+
diff --git a/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.spec.ts b/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.spec.ts index d7e0b53748..ea18f97537 100644 --- a/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.spec.ts +++ b/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.spec.ts @@ -8,6 +8,10 @@ import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { ClaimedTaskActionsRejectComponent } from './claimed-task-actions-reject.component'; import { MockTranslateLoader } from '../../../mocks/mock-translate-loader'; +import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { ProcessTaskResponse } from '../../../../core/tasks/models/process-task-response'; +import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; let component: ClaimedTaskActionsRejectComponent; let fixture: ComponentFixture; @@ -15,10 +19,15 @@ let formBuilder: FormBuilder; let modalService: NgbModal; describe('ClaimedTaskActionsRejectComponent', () => { + const object = Object.assign(new ClaimedTask(), { id: 'claimed-task-1' }); + const claimedTaskService = jasmine.createSpyObj('claimedTaskService', { + submitTask: observableOf(new ProcessTaskResponse(true)) + }); + beforeEach(async(() => { TestBed.configureTestingModule({ imports: [ - NgbModule.forRoot(), + NgbModule, ReactiveFormsModule, TranslateModule.forRoot({ loader: { @@ -29,6 +38,7 @@ describe('ClaimedTaskActionsRejectComponent', () => { ], declarations: [ClaimedTaskActionsRejectComponent], providers: [ + { provide: ClaimedTaskDataService, useValue: claimedTaskService }, FormBuilder, NgbModal ], @@ -43,17 +53,11 @@ describe('ClaimedTaskActionsRejectComponent', () => { component = fixture.componentInstance; formBuilder = TestBed.get(FormBuilder); modalService = TestBed.get(NgbModal); + component.object = object; component.modalRef = modalService.open('ok'); fixture.detectChanges(); }); - afterEach(() => { - fixture = null; - component = null; - modalService = null; - formBuilder = null; - }); - it('should init reject form properly', () => { expect(component.rejectForm).toBeDefined(); expect(component.rejectForm instanceof FormGroup).toBeTruthy(); @@ -67,7 +71,7 @@ describe('ClaimedTaskActionsRejectComponent', () => { }); it('should display spin icon when reject is pending', () => { - component.processingReject = true; + component.processing$.next(true); fixture.detectChanges(); const span = fixture.debugElement.query(By.css('.btn-danger .fa-spin')); @@ -75,7 +79,7 @@ describe('ClaimedTaskActionsRejectComponent', () => { expect(span).toBeDefined(); }); - it('should call openRejectModal on reject button click', fakeAsync(() => { + it('should call openRejectModal on reject button click', () => { spyOn(component.rejectForm, 'reset'); const btn = fixture.debugElement.query(By.css('.btn-danger')); btn.nativeElement.click(); @@ -85,24 +89,36 @@ describe('ClaimedTaskActionsRejectComponent', () => { expect(component.modalRef).toBeDefined(); component.modalRef.close() - })); + }); - it('should call confirmReject on form submit', fakeAsync(() => { - spyOn(component.reject, 'emit'); + describe('on form submit', () => { + let expectedBody; - const btn = fixture.debugElement.query(By.css('.btn-danger')); - btn.nativeElement.click(); - fixture.detectChanges(); + beforeEach(() => { + spyOn(component.processCompleted, 'emit'); - expect(component.modalRef).toBeDefined(); + expectedBody = { + [component.option]: 'true', + reason: null + }; - const form = ((document as any).querySelector('form')); - form.dispatchEvent(new Event('ngSubmit')); - fixture.detectChanges(); + const btn = fixture.debugElement.query(By.css('.btn-danger')); + btn.nativeElement.click(); + fixture.detectChanges(); - fixture.whenStable().then(() => { - expect(component.reject.emit).toHaveBeenCalled(); + expect(component.modalRef).toBeDefined(); + + const form = ((document as any).querySelector('form')); + form.dispatchEvent(new Event('ngSubmit')); + fixture.detectChanges(); }); - })); + it('should call claimedTaskService\'s submitTask with the expected body', () => { + expect(claimedTaskService.submitTask).toHaveBeenCalledWith(object.id, expectedBody) + }); + + it('should emit a successful processCompleted event', () => { + expect(component.processCompleted.emit).toHaveBeenCalledWith(true); + }); + }); }); diff --git a/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.ts b/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.ts index b66c104695..46d40cbb64 100644 --- a/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.ts +++ b/src/app/shared/mydspace-actions/claimed-task/reject/claimed-task-actions-reject.component.ts @@ -1,31 +1,27 @@ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { ClaimedTaskActionsAbstractComponent } from '../abstract/claimed-task-actions-abstract.component'; +import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; +import { rendersWorkflowTaskOption } from '../switcher/claimed-task-actions-decorator'; +export const WORKFLOW_TASK_OPTION_REJECT = 'submit_reject'; + +@rendersWorkflowTaskOption(WORKFLOW_TASK_OPTION_REJECT) @Component({ selector: 'ds-claimed-task-actions-reject', styleUrls: ['./claimed-task-actions-reject.component.scss'], templateUrl: './claimed-task-actions-reject.component.html', }) - -export class ClaimedTaskActionsRejectComponent implements OnInit { - +/** + * Component for displaying and processing the reject action on a workflow task item + */ +export class ClaimedTaskActionsRejectComponent extends ClaimedTaskActionsAbstractComponent implements OnInit { /** - * A boolean representing if a reject operation is pending + * This component represents the reject option */ - @Input() processingReject: boolean; - - /** - * CSS classes to append to reject button - */ - @Input() wrapperClass: string; - - /** - * An event fired when a reject action is confirmed. - * Event's payload equals to reject reason. - */ - @Output() reject: EventEmitter = new EventEmitter(); + option = WORKFLOW_TASK_OPTION_REJECT; /** * The reject form group @@ -42,8 +38,12 @@ export class ClaimedTaskActionsRejectComponent implements OnInit { * * @param {FormBuilder} formBuilder * @param {NgbModal} modalService + * @param claimedTaskService */ - constructor(private formBuilder: FormBuilder, private modalService: NgbModal) { + constructor(protected claimedTaskService: ClaimedTaskDataService, + private formBuilder: FormBuilder, + private modalService: NgbModal) { + super(claimedTaskService); } /** @@ -53,17 +53,23 @@ export class ClaimedTaskActionsRejectComponent implements OnInit { this.rejectForm = this.formBuilder.group({ reason: ['', Validators.required] }); - } /** - * Close modal and emit reject event + * Create the request body for rejecting a workflow task + * Includes the reason from the form */ - confirmReject() { - this.processingReject = true; - this.modalRef.close('Send Button'); + createbody(): any { const reason = this.rejectForm.get('reason').value; - this.reject.emit(reason); + return Object.assign(super.createbody(), { reason }); + } + + /** + * Submit a reject option for the task + */ + submitTask() { + this.modalRef.close('Send Button'); + super.submitTask(); } /** diff --git a/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.html b/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.html index 702ce75e7f..66f8e2a058 100644 --- a/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.html +++ b/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.html @@ -1,8 +1,8 @@ diff --git a/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.spec.ts b/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.spec.ts index d461d9e055..9b5a949d60 100644 --- a/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.spec.ts +++ b/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.spec.ts @@ -5,11 +5,20 @@ import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { ClaimedTaskActionsReturnToPoolComponent } from './claimed-task-actions-return-to-pool.component'; import { MockTranslateLoader } from '../../../mocks/mock-translate-loader'; +import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { ProcessTaskResponse } from '../../../../core/tasks/models/process-task-response'; +import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; let component: ClaimedTaskActionsReturnToPoolComponent; let fixture: ComponentFixture; describe('ClaimedTaskActionsReturnToPoolComponent', () => { + const object = Object.assign(new ClaimedTask(), { id: 'claimed-task-1' }); + const claimedTaskService = jasmine.createSpyObj('claimedTaskService', { + returnToPoolTask: observableOf(new ProcessTaskResponse(true)) + }); + beforeEach(async(() => { TestBed.configureTestingModule({ imports: [ @@ -20,6 +29,9 @@ describe('ClaimedTaskActionsReturnToPoolComponent', () => { } }) ], + providers: [ + { provide: ClaimedTaskDataService, useValue: claimedTaskService } + ], declarations: [ClaimedTaskActionsReturnToPoolComponent], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(ClaimedTaskActionsReturnToPoolComponent, { @@ -30,14 +42,10 @@ describe('ClaimedTaskActionsReturnToPoolComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(ClaimedTaskActionsReturnToPoolComponent); component = fixture.componentInstance; + component.object = object; fixture.detectChanges(); }); - afterEach(() => { - fixture = null; - component = null; - }); - it('should display return to pool button', () => { const btn = fixture.debugElement.query(By.css('.btn-secondary')); @@ -45,7 +53,7 @@ describe('ClaimedTaskActionsReturnToPoolComponent', () => { }); it('should display spin icon when return to pool action is pending', () => { - component.processingReturnToPool = true; + component.processing$.next(true); fixture.detectChanges(); const span = fixture.debugElement.query(By.css('.btn-secondary .fa-spin')); @@ -53,13 +61,21 @@ describe('ClaimedTaskActionsReturnToPoolComponent', () => { expect(span).toBeDefined(); }); - it('should emit return to pool event', () => { - spyOn(component.returnToPool, 'emit'); + describe('submitTask', () => { + beforeEach(() => { + spyOn(component.processCompleted, 'emit'); - component.confirmReturnToPool(); - fixture.detectChanges(); + component.submitTask(); + fixture.detectChanges(); + }); - expect(component.returnToPool.emit).toHaveBeenCalled(); + it('should call claimedTaskService\'s returnToPoolTask', () => { + expect(claimedTaskService.returnToPoolTask).toHaveBeenCalledWith(object.id) + }); + + it('should emit a successful processCompleted event', () => { + expect(component.processCompleted.emit).toHaveBeenCalledWith(true); + }); }); }); diff --git a/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.ts b/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.ts index 1dfe91eb5b..c53bf30fad 100644 --- a/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.ts +++ b/src/app/shared/mydspace-actions/claimed-task/return-to-pool/claimed-task-actions-return-to-pool.component.ts @@ -1,32 +1,39 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Component } from '@angular/core'; +import { ClaimedTaskActionsAbstractComponent } from '../abstract/claimed-task-actions-abstract.component'; +import { rendersWorkflowTaskOption } from '../switcher/claimed-task-actions-decorator'; +import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; +import { ProcessTaskResponse } from '../../../../core/tasks/models/process-task-response'; +export const WORKFLOW_TASK_OPTION_RETURN_TO_POOL = 'return_to_pool'; + +@rendersWorkflowTaskOption(WORKFLOW_TASK_OPTION_RETURN_TO_POOL) @Component({ selector: 'ds-claimed-task-actions-return-to-pool', styleUrls: ['./claimed-task-actions-return-to-pool.component.scss'], templateUrl: './claimed-task-actions-return-to-pool.component.html', }) +/** + * Component for displaying and processing the return to pool action on a workflow task item + */ +export class ClaimedTaskActionsReturnToPoolComponent extends ClaimedTaskActionsAbstractComponent { + /** + * This component represents the return to pool option + */ + option = WORKFLOW_TASK_OPTION_RETURN_TO_POOL; -export class ClaimedTaskActionsReturnToPoolComponent { + constructor(protected claimedTaskService: ClaimedTaskDataService) { + super(claimedTaskService); + } /** - * A boolean representing if a return to pool operation is pending + * Submit a return to pool option for the task */ - @Input() processingReturnToPool: boolean; - - /** - * CSS classes to append to return to pool button - */ - @Input() wrapperClass: string; - - /** - * An event fired when a return to pool action is confirmed. - */ - @Output() returnToPool: EventEmitter = new EventEmitter(); - - /** - * Emit returnToPool event - */ - confirmReturnToPool() { - this.returnToPool.emit(); + submitTask() { + this.processing$.next(true); + this.claimedTaskService.returnToPoolTask(this.object.id) + .subscribe((res: ProcessTaskResponse) => { + this.processing$.next(false); + this.processCompleted.emit(res.hasSucceeded); + }); } } diff --git a/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-decorator.spec.ts b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-decorator.spec.ts new file mode 100644 index 0000000000..04c3183a74 --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-decorator.spec.ts @@ -0,0 +1,39 @@ +import { getComponentByWorkflowTaskOption, rendersWorkflowTaskOption } from './claimed-task-actions-decorator'; + +describe('ClaimedTaskActions decorator function', () => { + const option1 = 'test_option_1'; + const option2 = 'test_option_2'; + const option3 = 'test_option_3'; + + /* tslint:disable:max-classes-per-file */ + class Test1Action {}; + class Test2Action {}; + class Test3Action {}; + /* tslint:enable:max-classes-per-file */ + + beforeAll(() => { + rendersWorkflowTaskOption(option1)(Test1Action); + rendersWorkflowTaskOption(option2)(Test2Action); + rendersWorkflowTaskOption(option3)(Test3Action); + }); + + describe('If there\'s an exact match', () => { + it('should return the matching class', () => { + const component = getComponentByWorkflowTaskOption(option1); + expect(component).toEqual(Test1Action); + + const component2 = getComponentByWorkflowTaskOption(option2); + expect(component2).toEqual(Test2Action); + + const component3 = getComponentByWorkflowTaskOption(option3); + expect(component3).toEqual(Test3Action); + }); + }); + + describe('If there\'s no match', () => { + it('should return unidentified', () => { + const component = getComponentByWorkflowTaskOption('non-existing-option'); + expect(component).toBeUndefined(); + }); + }); +}); diff --git a/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-decorator.ts b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-decorator.ts new file mode 100644 index 0000000000..a115c4e5b8 --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-decorator.ts @@ -0,0 +1,23 @@ +import { hasNoValue } from '../../../empty.util'; + +const map = new Map(); + +/** + * Decorator used for rendering ClaimedTaskActions pages by option type + */ +export function rendersWorkflowTaskOption(option: string) { + return function decorator(component: any) { + if (hasNoValue(map.get(option))) { + map.set(option, component); + } else { + throw new Error(`There can't be more than one component to render ClaimedTaskActions for option "${option}"`); + } + }; +} + +/** + * Get the component used for rendering a ClaimedTaskActions page by option type + */ +export function getComponentByWorkflowTaskOption(option: string) { + return map.get(option); +} diff --git a/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.html b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.html new file mode 100644 index 0000000000..364443c47f --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.html @@ -0,0 +1 @@ + diff --git a/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.spec.ts b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.spec.ts new file mode 100644 index 0000000000..b71adc7a25 --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.spec.ts @@ -0,0 +1,51 @@ +import { ClaimedTaskActionsLoaderComponent } from './claimed-task-actions-loader.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChangeDetectionStrategy, Component, ComponentFactoryResolver, NO_ERRORS_SCHEMA } from '@angular/core'; +import { spyOnExported } from '../../../testing/utils'; +import * as decorators from './claimed-task-actions-decorator'; +import { ClaimedTaskActionsDirective } from './claimed-task-actions.directive'; +import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; +import { TranslateModule } from '@ngx-translate/core'; +import { ClaimedTaskActionsEditMetadataComponent } from '../edit-metadata/claimed-task-actions-edit-metadata.component'; +import { ClaimedTaskDataService } from '../../../../core/tasks/claimed-task-data.service'; + +describe('ClaimedTaskActionsLoaderComponent', () => { + let comp: ClaimedTaskActionsLoaderComponent; + let fixture: ComponentFixture; + + const option = 'test_option'; + const object = Object.assign(new ClaimedTask(), { id: 'claimed-task-1' }); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot()], + declarations: [ClaimedTaskActionsLoaderComponent, ClaimedTaskActionsEditMetadataComponent, ClaimedTaskActionsDirective], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { provide: ClaimedTaskDataService, useValue: {} }, + ComponentFactoryResolver + ] + }).overrideComponent(ClaimedTaskActionsLoaderComponent, { + set: { + changeDetection: ChangeDetectionStrategy.Default, + entryComponents: [ClaimedTaskActionsEditMetadataComponent] + } + }).compileComponents(); + })); + + beforeEach(async(() => { + fixture = TestBed.createComponent(ClaimedTaskActionsLoaderComponent); + comp = fixture.componentInstance; + + comp.object = object; + comp.option = option; + spyOnExported(decorators, 'getComponentByWorkflowTaskOption').and.returnValue(ClaimedTaskActionsEditMetadataComponent); + fixture.detectChanges(); + })); + + describe('When the component is rendered', () => { + it('should call the getComponentByWorkflowTaskOption function with the right option', () => { + expect(decorators.getComponentByWorkflowTaskOption).toHaveBeenCalledWith(option); + }) + }); +}); diff --git a/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.ts b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.ts new file mode 100644 index 0000000000..d8c8ecccec --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component.ts @@ -0,0 +1,85 @@ +import { + Component, + ComponentFactoryResolver, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, + ViewChild +} from '@angular/core'; +import { getComponentByWorkflowTaskOption } from './claimed-task-actions-decorator'; +import { ClaimedTask } from '../../../../core/tasks/models/claimed-task-object.model'; +import { ClaimedTaskActionsDirective } from './claimed-task-actions.directive'; +import { ClaimedTaskActionsAbstractComponent } from '../abstract/claimed-task-actions-abstract.component'; +import { hasValue } from '../../../empty.util'; +import { Subscription } from 'rxjs/internal/Subscription'; + +@Component({ + selector: 'ds-claimed-task-actions-loader', + templateUrl: './claimed-task-actions-loader.component.html' +}) +/** + * Component for loading a ClaimedTaskAction component depending on the "option" input + * Passes on the ClaimedTask to the component and subscribes to the processCompleted output + */ +export class ClaimedTaskActionsLoaderComponent implements OnInit, OnDestroy { + /** + * The ClaimedTask object + */ + @Input() object: ClaimedTask; + + /** + * The name of the option to render + * Passed on to the decorator to fetch the relevant component for this option + */ + @Input() option: string; + + /** + * Emits the success or failure of a processed action + */ + @Output() processCompleted: EventEmitter = new EventEmitter(); + + /** + * Directive to determine where the dynamic child component is located + */ + @ViewChild(ClaimedTaskActionsDirective, {static: true}) claimedTaskActionsDirective: ClaimedTaskActionsDirective; + + /** + * Array to track all subscriptions and unsubscribe them onDestroy + * @type {Array} + */ + protected subs: Subscription[] = []; + + constructor(private componentFactoryResolver: ComponentFactoryResolver) { + } + + /** + * Fetch, create and initialize the relevant component + */ + ngOnInit(): void { + const comp = getComponentByWorkflowTaskOption(this.option); + if (hasValue(comp)) { + const componentFactory = this.componentFactoryResolver.resolveComponentFactory(comp); + + const viewContainerRef = this.claimedTaskActionsDirective.viewContainerRef; + viewContainerRef.clear(); + + const componentRef = viewContainerRef.createComponent(componentFactory); + const componentInstance = (componentRef.instance as ClaimedTaskActionsAbstractComponent); + componentInstance.object = this.object; + if (hasValue(componentInstance.processCompleted)) { + this.subs.push(componentInstance.processCompleted.subscribe((success) => this.processCompleted.emit(success))); + } + } + } + + /** + * Unsubscribe from open subscriptions + */ + ngOnDestroy(): void { + this.subs + .filter((subscription) => hasValue(subscription)) + .forEach((subscription) => subscription.unsubscribe()); + } +} diff --git a/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions.directive.ts b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions.directive.ts new file mode 100644 index 0000000000..a4a55b541b --- /dev/null +++ b/src/app/shared/mydspace-actions/claimed-task/switcher/claimed-task-actions.directive.ts @@ -0,0 +1,11 @@ +import { Directive, ViewContainerRef } from '@angular/core'; + +@Directive({ + selector: '[dsClaimedTaskActions]', +}) +/** + * Directive used as a hook to know where to inject the dynamic Claimed Task Actions component + */ +export class ClaimedTaskActionsDirective { + constructor(public viewContainerRef: ViewContainerRef) { } +} 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 ac2d911a2c..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'; @@ -72,7 +72,7 @@ describe('WorkspaceitemActionsComponent', () => { TestBed.configureTestingModule({ imports: [ - NgbModule.forRoot(), + NgbModule, TranslateModule.forRoot({ loader: { provide: TranslateLoader, @@ -128,7 +128,7 @@ describe('WorkspaceitemActionsComponent', () => { expect(btn).toBeDefined(); }); - it('should call confirmDiscard on discard confirmation', fakeAsync(() => { + it('should call confirmDiscard on discard confirmation', () => { mockDataService.delete.and.returnValue(observableOf(true)); spyOn(component, 'reload'); const btn = fixture.debugElement.query(By.css('.btn-danger')); @@ -141,10 +141,10 @@ describe('WorkspaceitemActionsComponent', () => { fixture.detectChanges(); fixture.whenStable().then(() => { - expect(mockDataService.delete).toHaveBeenCalledWith(mockObject); + expect(mockDataService.delete).toHaveBeenCalledWith(mockObject.id); }); - })); + }); it('should display a success notification on delete success', async(() => { spyOn((component as any).modalService, 'open').and.returnValue({result: Promise.resolve('ok')}); 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/number-picker/number-picker.component.spec.ts b/src/app/shared/number-picker/number-picker.component.spec.ts index 3703be97df..82d4329bec 100644 --- a/src/app/shared/number-picker/number-picker.component.spec.ts +++ b/src/app/shared/number-picker/number-picker.component.spec.ts @@ -24,7 +24,7 @@ describe('NumberPickerComponent test suite', () => { imports: [ FormsModule, ReactiveFormsModule, - NgbModule.forRoot() + NgbModule ], declarations: [ NumberPickerComponent, diff --git a/src/app/shared/object-collection/object-collection.component.html b/src/app/shared/object-collection/object-collection.component.html index 57e1ccd81a..e696170a6f 100644 --- a/src/app/shared/object-collection/object-collection.component.html +++ b/src/app/shared/object-collection/object-collection.component.html @@ -5,6 +5,7 @@ [hideGear]="hideGear" [linkType]="linkType" [context]="context" + [hidePaginationDetail]="hidePaginationDetail" (paginationChange)="onPaginationChange($event)" (pageChange)="onPageChange($event)" (pageSizeChange)="onPageSizeChange($event)" @@ -14,6 +15,9 @@ (sortFieldChange)="onSortFieldChange($event)" [selectable]="selectable" [selectionConfig]="selectionConfig" + [importable]="importable" + [importConfig]="importConfig" + (importObject)="importObject.emit($event)" *ngIf="(currentMode$ | async) === viewModeEnum.ListElement"> @@ -23,6 +27,7 @@ [hideGear]="hideGear" [linkType]="linkType" [context]="context" + [hidePaginationDetail]="hidePaginationDetail" (paginationChange)="onPaginationChange($event)" (pageChange)="onPageChange($event)" (pageSizeChange)="onPageSizeChange($event)" @@ -37,6 +42,7 @@ [hideGear]="hideGear" [linkType]="linkType" [context]="context" + [hidePaginationDetail]="hidePaginationDetail" *ngIf="(currentMode$ | async) === viewModeEnum.DetailedListElement"> diff --git a/src/app/shared/object-collection/object-collection.component.ts b/src/app/shared/object-collection/object-collection.component.ts index f09ba3953e..f39bf07123 100644 --- a/src/app/shared/object-collection/object-collection.component.ts +++ b/src/app/shared/object-collection/object-collection.component.ts @@ -53,6 +53,21 @@ export class ObjectCollectionComponent implements OnInit { @Output() deselectObject: EventEmitter = new EventEmitter(); @Output() selectObject: EventEmitter = new EventEmitter(); + /** + * Whether or not to add an import button to the object elements + */ + @Input() importable = false; + + /** + * The config to use for the import button + */ + @Input() importConfig: { buttonLabel: string }; + + /** + * Send an import event to the parent component + */ + @Output() importObject: EventEmitter = new EventEmitter(); + /** * The link type of the rendered list elements */ @@ -63,6 +78,11 @@ export class ObjectCollectionComponent implements OnInit { */ @Input() context: Context; + /** + * Option for hiding the pagination detail + */ + @Input() hidePaginationDetail = false; + /** * the page info of the list */ diff --git a/src/app/shared/object-collection/shared/importable-list-item-control/importable-list-item-control.component.html b/src/app/shared/object-collection/shared/importable-list-item-control/importable-list-item-control.component.html new file mode 100644 index 0000000000..ca3b086653 --- /dev/null +++ b/src/app/shared/object-collection/shared/importable-list-item-control/importable-list-item-control.component.html @@ -0,0 +1,7 @@ +
+ +
diff --git a/src/app/shared/object-collection/shared/importable-list-item-control/importable-list-item-control.component.ts b/src/app/shared/object-collection/shared/importable-list-item-control/importable-list-item-control.component.ts new file mode 100644 index 0000000000..f381a02d86 --- /dev/null +++ b/src/app/shared/object-collection/shared/importable-list-item-control/importable-list-item-control.component.ts @@ -0,0 +1,26 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { ListableObject } from '../listable-object.model'; + +@Component({ + selector: 'ds-importable-list-item-control', + templateUrl: './importable-list-item-control.component.html' +}) +/** + * Component adding an import button to a list item + */ +export class ImportableListItemControlComponent { + /** + * The item or metadata to determine the component for + */ + @Input() object: ListableObject; + + /** + * Extra configuration for the import button + */ + @Input() importConfig: { buttonLabel: string }; + + /** + * Output the object to import + */ + @Output() importObject: EventEmitter = new EventEmitter(); +} diff --git a/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.ts b/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.ts index 25232efa1d..4e6e206ddd 100644 --- a/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.ts +++ b/src/app/shared/object-collection/shared/listable-object/listable-object-component-loader.component.ts @@ -1,4 +1,4 @@ -import { Component, ComponentFactoryResolver, Input, OnInit, ViewChild } from '@angular/core'; +import { Component, ComponentFactoryResolver, ContentChild, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; import { ListableObject } from '../listable-object.model'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { Context } from '../../../../core/shared/context.model'; @@ -49,7 +49,7 @@ export class ListableObjectComponentLoaderComponent implements OnInit { /** * Directive hook used to place the dynamic child component */ - @ViewChild(ListableObjectDirective) listableObjectDirective: ListableObjectDirective; + @ViewChild(ListableObjectDirective, {static: true}) listableObjectDirective: ListableObjectDirective; constructor(private componentFactoryResolver: ComponentFactoryResolver) { } 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..f7ed66488d 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,17 @@ describe('ClaimedTaskSearchResultDetailElementComponent', () => { fixture.detectChanges(); }); - it('should init item properly', () => { - expect(component.workflowitem).toEqual(workflowitem); + it('should init workflowitem properly', (done) => { + component.workflowitemRD$.subscribe((workflowitemRD) => { + // Make sure the necessary links are being resolved + expect(linkService.resolveLinks).toHaveBeenCalledWith( + component.dso, + jasmine.objectContaining({ name: 'workflowitem' }), + jasmine.objectContaining({ name: 'action' }) + ); + 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..359d3abcdc 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.resolveLinks(this.dso, followLink('workflowitem', null, true, + followLink('item', null, true, followLink('bundles')), + followLink('submitter') + ), followLink('action')); + 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 07f3960d55..d065f9c7e4 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 @@ -1,12 +1,24 @@ -import { CollectionSearchResultGridElementComponent } from './collection-search-result-grid-element.component'; -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { of as observableOf } from 'rxjs'; +import { HttpClient } from '@angular/common/http'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { TruncatePipe } from '../../../utils/truncate.pipe'; +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'; import { Collection } from '../../../../core/shared/collection.model'; -import { TruncatableService } from '../../../truncatable/truncatable.service'; +import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service'; +import { UUIDService } from '../../../../core/shared/uuid.service'; +import { NotificationsService } from '../../../notifications/notifications.service'; import { CollectionSearchResult } from '../../../object-collection/shared/collection-search-result.model'; +import { TruncatableService } from '../../../truncatable/truncatable.service'; +import { TruncatePipe } from '../../../utils/truncate.pipe'; +import { CollectionSearchResultGridElementComponent } from './collection-search-result-grid-element.component'; +import { BitstreamFormatDataService } from '../../../../core/data/bitstream-format-data.service'; let collectionSearchResultGridElementComponent: CollectionSearchResultGridElementComponent; let fixture: ComponentFixture; @@ -47,7 +59,19 @@ describe('CollectionSearchResultGridElementComponent', () => { declarations: [ CollectionSearchResultGridElementComponent, TruncatePipe ], providers: [ { provide: TruncatableService, useValue: truncatableServiceStub }, - { provide: 'objectElementProvider', useValue: (mockCollectionWithAbstract) } + { provide: 'objectElementProvider', useValue: (mockCollectionWithAbstract) }, + { provide: ObjectCacheService, useValue: {} }, + { provide: UUIDService, useValue: {} }, + { provide: Store, useValue: {} }, + { provide: RemoteDataBuildService, useValue: {} }, + { provide: BitstreamDataService, useValue: {} }, + { provide: CommunityDataService, useValue: {} }, + { provide: HALEndpointService, useValue: {} }, + { provide: NotificationsService, useValue: {} }, + { provide: HttpClient, useValue: {} }, + { provide: DSOChangeAnalyzer, useValue: {} }, + { provide: DefaultChangeAnalyzer, useValue: {} }, + { provide: BitstreamFormatDataService, useValue: {} }, ], schemas: [ NO_ERRORS_SCHEMA ] 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 567b2e1d0e..0d59273111 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 @@ -1,12 +1,24 @@ -import { CommunitySearchResultGridElementComponent } from './community-search-result-grid-element.component'; -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { of as observableOf } from 'rxjs'; +import { HttpClient } from '@angular/common/http'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { TruncatePipe } from '../../../utils/truncate.pipe'; +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'; import { Community } from '../../../../core/shared/community.model'; -import { TruncatableService } from '../../../truncatable/truncatable.service'; +import { HALEndpointService } from '../../../../core/shared/hal-endpoint.service'; +import { UUIDService } from '../../../../core/shared/uuid.service'; +import { NotificationsService } from '../../../notifications/notifications.service'; import { CommunitySearchResult } from '../../../object-collection/shared/community-search-result.model'; +import { TruncatableService } from '../../../truncatable/truncatable.service'; +import { TruncatePipe } from '../../../utils/truncate.pipe'; +import { CommunitySearchResultGridElementComponent } from './community-search-result-grid-element.component'; +import { BitstreamFormatDataService } from '../../../../core/data/bitstream-format-data.service'; let communitySearchResultGridElementComponent: CommunitySearchResultGridElementComponent; let fixture: ComponentFixture; @@ -47,7 +59,19 @@ describe('CommunitySearchResultGridElementComponent', () => { declarations: [ CommunitySearchResultGridElementComponent, TruncatePipe ], providers: [ { provide: TruncatableService, useValue: truncatableServiceStub }, - { provide: 'objectElementProvider', useValue: (mockCommunityWithAbstract) } + { provide: 'objectElementProvider', useValue: (mockCommunityWithAbstract) }, + { provide: ObjectCacheService, useValue: {} }, + { provide: UUIDService, useValue: {} }, + { provide: Store, useValue: {} }, + { provide: RemoteDataBuildService, useValue: {} }, + { provide: BitstreamDataService, useValue: {} }, + { provide: CommunityDataService, useValue: {} }, + { provide: HALEndpointService, useValue: {} }, + { provide: NotificationsService, useValue: {} }, + { provide: HttpClient, useValue: {} }, + { provide: DSOChangeAnalyzer, useValue: {} }, + { provide: DefaultChangeAnalyzer, useValue: {} }, + { provide: BitstreamFormatDataService, useValue: {} }, ], schemas: [ NO_ERRORS_SCHEMA ] 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 a00e30cbcd..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.spec.ts b/src/app/shared/object-grid/search-result-grid-element/item-search-result/publication/publication-search-result-grid-element.component.spec.ts index 69e50c7300..47531e044c 100644 --- a/src/app/shared/object-grid/search-result-grid-element/item-search-result/publication/publication-search-result-grid-element.component.spec.ts +++ b/src/app/shared/object-grid/search-result-grid-element/item-search-result/publication/publication-search-result-grid-element.component.spec.ts @@ -1,15 +1,29 @@ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { TruncatePipe } from '../../../../utils/truncate.pipe'; -import { TruncatableService } from '../../../../truncatable/truncatable.service'; +import { HttpClient } from '@angular/common/http'; import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; +import { async, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs/internal/Observable'; import { of as observableOf } from 'rxjs/internal/observable/of'; -import { ItemSearchResult } from '../../../../object-collection/shared/item-search-result.model'; -import { Item } from '../../../../../core/shared/item.model'; -import { createSuccessfulRemoteDataObject$ } from '../../../../testing/utils'; +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'; import { PaginatedList } from '../../../../../core/data/paginated-list'; +import { RemoteData } from '../../../../../core/data/remote-data'; +import { Bitstream } from '../../../../../core/shared/bitstream.model'; +import { HALEndpointService } from '../../../../../core/shared/hal-endpoint.service'; +import { Item } from '../../../../../core/shared/item.model'; import { PageInfo } from '../../../../../core/shared/page-info.model'; +import { UUIDService } from '../../../../../core/shared/uuid.service'; +import { NotificationsService } from '../../../../notifications/notifications.service'; +import { ItemSearchResult } from '../../../../object-collection/shared/item-search-result.model'; +import { createSuccessfulRemoteDataObject$ } from '../../../../testing/utils'; +import { TruncatableService } from '../../../../truncatable/truncatable.service'; +import { TruncatePipe } from '../../../../utils/truncate.pipe'; import { PublicationSearchResultGridElementComponent } from './publication-search-result-grid-element.component'; const mockItemWithMetadata: ItemSearchResult = new ItemSearchResult(); @@ -78,12 +92,29 @@ export function getEntityGridElementTestComponent(component, searchResultWithMet isCollapsed: (id: number) => observableOf(true), }; + const mockBitstreamDataService = { + getThumbnailFor(item: Item): Observable> { + return createSuccessfulRemoteDataObject$(new Bitstream()); + } + }; + beforeEach(async(() => { TestBed.configureTestingModule({ imports: [NoopAnimationsModule], declarations: [component, TruncatePipe], providers: [ { provide: TruncatableService, useValue: truncatableServiceStub }, + { provide: ObjectCacheService, useValue: {} }, + { provide: UUIDService, useValue: {} }, + { provide: Store, useValue: {} }, + { provide: RemoteDataBuildService, useValue: {} }, + { provide: CommunityDataService, useValue: {} }, + { provide: HALEndpointService, useValue: {} }, + { provide: HttpClient, useValue: {} }, + { provide: DSOChangeAnalyzer, useValue: {} }, + { provide: NotificationsService, useValue: {} }, + { provide: DefaultChangeAnalyzer, useValue: {} }, + { provide: BitstreamDataService, useValue: mockBitstreamDataService }, ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(component, { 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-grid/search-result-grid-element/search-result-grid-element.component.scss b/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.scss index dc9f9b3969..efc4d3c414 100644 --- a/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.scss +++ b/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.scss @@ -1,5 +1,5 @@ :host { - /deep/ em { + ::ng-deep em { font-weight: bold; font-style: normal; } diff --git a/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts b/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts index 8587e91302..dc05f78e40 100644 --- a/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts +++ b/src/app/shared/object-grid/search-result-grid-element/search-result-grid-element.component.ts @@ -1,12 +1,15 @@ -import { Component, Inject, OnInit } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; import { SearchResult } from '../../search/search-result.model'; +import { BitstreamDataService } from '../../../core/data/bitstream-data.service'; +import { Bitstream } from '../../../core/shared/bitstream.model'; import { DSpaceObject } from '../../../core/shared/dspace-object.model'; +import { Metadata } from '../../../core/shared/metadata.utils'; +import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators'; +import { hasValue } from '../../empty.util'; import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component'; import { TruncatableService } from '../../truncatable/truncatable.service'; -import { Observable } from 'rxjs'; -import { Metadata } from '../../../core/shared/metadata.utils'; -import { hasValue } from '../../empty.util'; @Component({ selector: 'ds-search-result-grid-element', @@ -24,7 +27,10 @@ export class SearchResultGridElementComponent, K exten */ isCollapsed$: Observable; - public constructor(protected truncatableService: TruncatableService) { + public constructor( + protected truncatableService: TruncatableService, + protected bitstreamDataService: BitstreamDataService + ) { super(); if (hasValue(this.object)) { this.isCollapsed$ = this.isCollapsed(); @@ -63,4 +69,11 @@ export class SearchResultGridElementComponent, K exten private isCollapsed(): Observable { return this.truncatableService.isCollapsed(this.dso.id); } + + // TODO refactor to return RemoteData, and thumbnail template to deal with loading + getThumbnail(): Observable { + return this.bitstreamDataService.getThumbnailFor(this.dso as any).pipe( + getFirstSucceededRemoteDataPayload() + ); + } } diff --git a/src/app/shared/object-list/bundle-list-element/bundle-list-element.component.html b/src/app/shared/object-list/bundle-list-element/bundle-list-element.component.html new file mode 100644 index 0000000000..dfe08144a8 --- /dev/null +++ b/src/app/shared/object-list/bundle-list-element/bundle-list-element.component.html @@ -0,0 +1 @@ +
{{object.name}}
diff --git a/src/app/shared/object-list/bundle-list-element/bundle-list-element.component.ts b/src/app/shared/object-list/bundle-list-element/bundle-list-element.component.ts new file mode 100644 index 0000000000..55eb5b116e --- /dev/null +++ b/src/app/shared/object-list/bundle-list-element/bundle-list-element.component.ts @@ -0,0 +1,16 @@ +import { AbstractListableElementComponent } from '../../object-collection/shared/object-collection-element/abstract-listable-element.component'; +import { Bundle } from '../../../core/shared/bundle.model'; +import { Component } from '@angular/core'; +import { listableObjectComponent } from '../../object-collection/shared/listable-object/listable-object.decorator'; +import { ViewMode } from '../../../core/shared/view-mode.model'; + +@Component({ + selector: 'ds-bundle-list-element', + templateUrl: './bundle-list-element.component.html' +}) +/** + * This component is automatically used to create a list view for Bundle objects + */ +@listableObjectComponent(Bundle, ViewMode.ListElement) +export class BundleListElementComponent extends AbstractListableElementComponent { +} 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..e2102fe9b7 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,16 @@ 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.resolveLinks).toHaveBeenCalledWith( + component.dso, + jasmine.objectContaining({ name: 'workflowitem' }), + jasmine.objectContaining({ name: 'action' }) + ); + 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..d149595514 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.resolveLinks(this.dso, followLink('workflowitem', null, true, + followLink('item'), followLink('submitter') + ), followLink('action')); + this.workflowitemRD$ = this.dso.workflowitem as Observable>; } } diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html index bcd5c3c027..e1478b5206 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.html @@ -2,6 +2,7 @@ +

diff --git a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.spec.ts index eb531d2f93..6366539157 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.spec.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component.spec.ts @@ -47,6 +47,23 @@ const mockItemWithoutAuthorAndDate: Item = Object.assign(new Item(), { ] } }); +const mockItemWithEntityType: Item = Object.assign(new Item(), { + bundles: observableOf({}), + metadata: { + 'dc.title': [ + { + language: 'en_US', + value: 'This is just another title' + } + ], + 'relationship.type': [ + { + language: null, + value: 'Publication' + } + ] + } +}); describe('ItemListPreviewComponent', () => { beforeEach(async(() => { @@ -128,4 +145,16 @@ describe('ItemListPreviewComponent', () => { expect(dateField).not.toBeNull(); }); }); + + describe('When the item has an entity type', () => { + beforeEach(() => { + component.item = mockItemWithEntityType; + fixture.detectChanges(); + }); + + it('should show the entity type span', () => { + const entityField = fixture.debugElement.query(By.css('ds-item-type-badge')); + expect(entityField).not.toBeNull(); + }); + }); }); 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..adf534bb57 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,16 @@ 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.resolveLinks).toHaveBeenCalledWith( + component.dso, + jasmine.objectContaining({ name: 'workflowitem' }), + jasmine.objectContaining({ name: 'action' }) + ); + 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..0953af3c76 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.resolveLinks(this.dso, followLink('workflowitem', null, true, + followLink('item'), followLink('submitter') + ), followLink('action')); + this.workflowitemRD$ = this.dso.workflowitem as Observable>; } } diff --git a/src/app/shared/object-list/my-dspace-result-list-element/workflow-item-search-result/workflow-item-search-result-list-element.component.html b/src/app/shared/object-list/my-dspace-result-list-element/workflow-item-search-result/workflow-item-search-result-list-element.component.html index 782c5f9e56..ced2846b4b 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/workflow-item-search-result/workflow-item-search-result-list-element.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/workflow-item-search-result/workflow-item-search-result-list-element.component.html @@ -1,6 +1,11 @@ - - - + + + + + diff --git a/src/app/shared/object-list/my-dspace-result-list-element/workflow-item-search-result/workflow-item-search-result-list-element.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/workflow-item-search-result/workflow-item-search-result-list-element.component.spec.ts index f5521001ff..9cbbd666cd 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/workflow-item-search-result/workflow-item-search-result-list-element.component.spec.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/workflow-item-search-result/workflow-item-search-result-list-element.component.spec.ts @@ -3,14 +3,18 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { of as observableOf } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { LinkService } from '../../../../core/cache/builders/link.service'; +import { ItemDataService } from '../../../../core/data/item-data.service'; import { Item } from '../../../../core/shared/item.model'; import { WorkflowItem } from '../../../../core/submission/models/workflowitem.model'; +import { getMockLinkService } from '../../../mocks/mock-link-service'; import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; -import { createSuccessfulRemoteDataObject } from '../../../testing/utils'; -import { WorkflowItemSearchResultListElementComponent } from './workflow-item-search-result-list-element.component'; import { WorkflowItemSearchResult } from '../../../object-collection/shared/workflow-item-search-result.model'; +import { createSuccessfulRemoteDataObject } from '../../../testing/utils'; import { TruncatableService } from '../../../truncatable/truncatable.service'; +import { WorkflowItemSearchResultListElementComponent } from './workflow-item-search-result-list-element.component'; let component: WorkflowItemSearchResultListElementComponent; let fixture: ComponentFixture; @@ -52,13 +56,18 @@ const item = Object.assign(new Item(), { const rd = createSuccessfulRemoteDataObject(item); mockResultObject.indexableObject = Object.assign(new WorkflowItem(), { item: observableOf(rd) }); +let linkService; + describe('WorkflowItemSearchResultListElementComponent', () => { beforeEach(async(() => { + linkService = getMockLinkService(); TestBed.configureTestingModule({ imports: [NoopAnimationsModule], declarations: [WorkflowItemSearchResultListElementComponent], providers: [ { provide: TruncatableService, useValue: {} }, + { provide: ItemDataService, useValue: {} }, + { provide: LinkService, useValue: linkService }, ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(WorkflowItemSearchResultListElementComponent, { @@ -76,8 +85,12 @@ describe('WorkflowItemSearchResultListElementComponent', () => { fixture.detectChanges(); }); - it('should init item properly', () => { - expect(component.item).toEqual(item); + it('should init item properly', (done) => { + component.item$.pipe(take(1)).subscribe((i) => { + expect(linkService.resolveLink).toHaveBeenCalled(); + expect(i).toBe(item); + done(); + }); }); it('should have properly status', () => { diff --git a/src/app/shared/object-list/my-dspace-result-list-element/workflow-item-search-result/workflow-item-search-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/workflow-item-search-result/workflow-item-search-result-list-element.component.ts index faf03425f0..432f69f28c 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/workflow-item-search-result/workflow-item-search-result-list-element.component.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/workflow-item-search-result/workflow-item-search-result-list-element.component.ts @@ -1,16 +1,19 @@ import { Component } from '@angular/core'; import { Observable } from 'rxjs'; -import { find } from 'rxjs/operators'; +import { find, map } from 'rxjs/operators'; +import { LinkService } from '../../../../core/cache/builders/link.service'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { Item } from '../../../../core/shared/item.model'; 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 { Item } from '../../../../core/shared/item.model'; -import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; +import { isNotUndefined } from '../../../empty.util'; import { listableObjectComponent } from '../../../object-collection/shared/listable-object/listable-object.decorator'; +import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; import { WorkflowItemSearchResult } from '../../../object-collection/shared/workflow-item-search-result.model'; +import { TruncatableService } from '../../../truncatable/truncatable.service'; +import { followLink } from '../../../utils/follow-link-config.model'; import { SearchResultListElementComponent } from '../../search-result-list-element/search-result-list-element.component'; /** @@ -28,18 +31,26 @@ export class WorkflowItemSearchResultListElementComponent extends SearchResultLi /** * The item object that belonging to the result object */ - public item: Item; + public item$: Observable; /** * Represent item's status */ public status = MyDspaceItemStatusType.WORKFLOW; + constructor( + protected truncatableService: TruncatableService, + protected linkService: LinkService + ) { + super(truncatableService); + } + /** * Initialize all instance variables */ ngOnInit() { super.ngOnInit(); + this.linkService.resolveLink(this.dso, followLink('item')); this.initItem(this.dso.item as Observable> ); } @@ -47,11 +58,10 @@ export class WorkflowItemSearchResultListElementComponent extends SearchResultLi * Retrieve item from result object */ initItem(item$: Observable>) { - item$.pipe( - find((rd: RemoteData) => rd.hasSucceeded && isNotUndefined(rd.payload)) - ).subscribe((rd: RemoteData) => { - this.item = rd.payload; - }); + this.item$ = item$.pipe( + find((rd: RemoteData) => rd.hasSucceeded && isNotUndefined(rd.payload)), + map((rd: RemoteData) => rd.payload) + ); } } diff --git a/src/app/shared/object-list/my-dspace-result-list-element/workspace-item-search-result/workspace-item-search-result-list-element.component.html b/src/app/shared/object-list/my-dspace-result-list-element/workspace-item-search-result/workspace-item-search-result-list-element.component.html index 79a31770d6..8966b4b1d8 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/workspace-item-search-result/workspace-item-search-result-list-element.component.html +++ b/src/app/shared/object-list/my-dspace-result-list-element/workspace-item-search-result/workspace-item-search-result-list-element.component.html @@ -1,7 +1,11 @@ - + + - + + + diff --git a/src/app/shared/object-list/my-dspace-result-list-element/workspace-item-search-result/workspace-item-search-result-list-element.component.spec.ts b/src/app/shared/object-list/my-dspace-result-list-element/workspace-item-search-result/workspace-item-search-result-list-element.component.spec.ts index faf4a3b1be..441800c8db 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/workspace-item-search-result/workspace-item-search-result-list-element.component.spec.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/workspace-item-search-result/workspace-item-search-result-list-element.component.spec.ts @@ -3,14 +3,18 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { of as observableOf } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { LinkService } from '../../../../core/cache/builders/link.service'; +import { ItemDataService } from '../../../../core/data/item-data.service'; import { Item } from '../../../../core/shared/item.model'; -import { WorkspaceItemSearchResultListElementComponent } from './workspace-item-search-result-list-element.component'; import { WorkspaceItem } from '../../../../core/submission/models/workspaceitem.model'; +import { getMockLinkService } from '../../../mocks/mock-link-service'; import { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; -import { createSuccessfulRemoteDataObject } from '../../../testing/utils'; import { WorkflowItemSearchResult } from '../../../object-collection/shared/workflow-item-search-result.model'; +import { createSuccessfulRemoteDataObject } from '../../../testing/utils'; import { TruncatableService } from '../../../truncatable/truncatable.service'; +import { WorkspaceItemSearchResultListElementComponent } from './workspace-item-search-result-list-element.component'; let component: WorkspaceItemSearchResultListElementComponent; let fixture: ComponentFixture; @@ -51,14 +55,18 @@ const item = Object.assign(new Item(), { }); const rd = createSuccessfulRemoteDataObject(item); mockResultObject.indexableObject = Object.assign(new WorkspaceItem(), { item: observableOf(rd) }); +let linkService; describe('WorkspaceItemSearchResultListElementComponent', () => { beforeEach(async(() => { + linkService = getMockLinkService(); TestBed.configureTestingModule({ imports: [NoopAnimationsModule], declarations: [WorkspaceItemSearchResultListElementComponent], providers: [ { provide: TruncatableService, useValue: {} }, + { provide: ItemDataService, useValue: {} }, + { provide: LinkService, useValue: linkService }, ], schemas: [NO_ERRORS_SCHEMA] }).overrideComponent(WorkspaceItemSearchResultListElementComponent, { @@ -76,8 +84,12 @@ describe('WorkspaceItemSearchResultListElementComponent', () => { fixture.detectChanges(); }); - it('should init item properly', () => { - expect(component.item).toEqual(item); + it('should init item properly', (done) => { + component.item$.pipe(take(1)).subscribe((i) => { + expect(linkService.resolveLink).toHaveBeenCalled(); + expect(i).toBe(item); + done(); + }); }); it('should have properly status', () => { diff --git a/src/app/shared/object-list/my-dspace-result-list-element/workspace-item-search-result/workspace-item-search-result-list-element.component.ts b/src/app/shared/object-list/my-dspace-result-list-element/workspace-item-search-result/workspace-item-search-result-list-element.component.ts index 830726c677..b9d89ef6ab 100644 --- a/src/app/shared/object-list/my-dspace-result-list-element/workspace-item-search-result/workspace-item-search-result-list-element.component.ts +++ b/src/app/shared/object-list/my-dspace-result-list-element/workspace-item-search-result/workspace-item-search-result-list-element.component.ts @@ -1,16 +1,19 @@ import { Component } from '@angular/core'; import { Observable } from 'rxjs'; -import { find } from 'rxjs/operators'; +import { find, map } from 'rxjs/operators'; +import { LinkService } from '../../../../core/cache/builders/link.service'; +import { RemoteData } from '../../../../core/data/remote-data'; +import { Item } from '../../../../core/shared/item.model'; import { ViewMode } from '../../../../core/shared/view-mode.model'; import { WorkspaceItem } from '../../../../core/submission/models/workspaceitem.model'; -import { RemoteData } from '../../../../core/data/remote-data'; import { isNotUndefined } from '../../../empty.util'; -import { Item } from '../../../../core/shared/item.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 { MyDspaceItemStatusType } from '../../../object-collection/shared/mydspace-item-status/my-dspace-item-status-type'; import { WorkspaceItemSearchResult } from '../../../object-collection/shared/workspace-item-search-result.model'; +import { TruncatableService } from '../../../truncatable/truncatable.service'; +import { followLink } from '../../../utils/follow-link-config.model'; import { SearchResultListElementComponent } from '../../search-result-list-element/search-result-list-element.component'; /** @@ -28,18 +31,26 @@ export class WorkspaceItemSearchResultListElementComponent extends SearchResultL /** * The item object that belonging to the result object */ - item: Item; + item$: Observable; /** * Represent item's status */ status = MyDspaceItemStatusType.WORKSPACE; + constructor( + protected truncatableService: TruncatableService, + protected linkService: LinkService + ) { + super(truncatableService); + } + /** * Initialize all instance variables */ ngOnInit() { super.ngOnInit(); + this.linkService.resolveLink(this.dso, followLink('item')); this.initItem(this.dso.item as Observable>); } @@ -47,10 +58,9 @@ export class WorkspaceItemSearchResultListElementComponent extends SearchResultL * Retrieve item from result object */ initItem(item$: Observable>) { - item$.pipe( - find((rd: RemoteData) => rd.hasSucceeded && isNotUndefined(rd.payload)) - ).subscribe((rd: RemoteData) => { - this.item = rd.payload; - }); + this.item$ = item$.pipe( + find((rd: RemoteData) => rd.hasSucceeded && isNotUndefined(rd.payload)), + map((rd: RemoteData) => rd.payload) + ); } } diff --git a/src/app/shared/object-list/object-list.component.html b/src/app/shared/object-list/object-list.component.html index 887be96785..5f6b1d1ec8 100644 --- a/src/app/shared/object-list/object-list.component.html +++ b/src/app/shared/object-list/object-list.component.html @@ -5,6 +5,7 @@ [sortOptions]="sortConfig" [hideGear]="hideGear" [hidePagerWhenSinglePage]="hidePagerWhenSinglePage" + [hidePaginationDetail]="hidePaginationDetail" (pageChange)="onPageChange($event)" (pageSizeChange)="onPageSizeChange($event)" (sortDirectionChange)="onSortDirectionChange($event)" @@ -19,6 +20,11 @@ (deselectObject)="deselectObject.emit($event)" (selectObject)="selectObject.emit($event)"> + + + diff --git a/src/app/shared/object-list/object-list.component.ts b/src/app/shared/object-list/object-list.component.ts index 6ca7adb3f9..60544c4ec5 100644 --- a/src/app/shared/object-list/object-list.component.ts +++ b/src/app/shared/object-list/object-list.component.ts @@ -61,6 +61,21 @@ export class ObjectListComponent { */ @Input() context: Context; + /** + * Option for hiding the pagination detail + */ + @Input() hidePaginationDetail = false; + + /** + * Whether or not to add an import button to the object + */ + @Input() importable = false; + + /** + * Config used for the import button + */ + @Input() importConfig: { importLabel: string }; + /** * The current listable objects */ @@ -119,6 +134,12 @@ export class ObjectListComponent { @Output() deselectObject: EventEmitter = new EventEmitter(); @Output() selectObject: EventEmitter = new EventEmitter(); + + /** + * Send an import event to the parent component + */ + @Output() importObject: EventEmitter = new EventEmitter(); + /** * An event fired when the sort field is changed. * Event's payload equals to the newly selected sort field. diff --git a/src/app/shared/object.util.ts b/src/app/shared/object.util.ts index 60ed71096a..02f7c54e1d 100644 --- a/src/app/shared/object.util.ts +++ b/src/app/shared/object.util.ts @@ -5,7 +5,7 @@ import { isEqual, isObject, transform } from 'lodash'; * Returns passed object without specified property */ export function deleteProperty(object: object, key: string): object { - const {[key]: deletedKey, ...otherKeys} = object; + const { [key]: deletedKey, ...otherKeys } = object as { [key: string]: any }; return otherKeys; } @@ -47,7 +47,7 @@ export function difference(object: object, base: object) { const changes = (o, b) => { return transform(o, (result, value, key) => { if (!isEqual(value, b[key]) && isNotEmpty(value)) { - const resultValue = (isObject(value) && isObject(b[key])) ? changes(value, b[key]) : value; + const resultValue = (isObject(value) && isObject(b[key])) ? changes(value, b[key]) : value as object; if (!hasOnlyEmptyProperties(resultValue)) { result[key] = resultValue; } diff --git a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.spec.ts b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.spec.ts new file mode 100644 index 0000000000..84f3381880 --- /dev/null +++ b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.spec.ts @@ -0,0 +1,178 @@ +import { AbstractPaginatedDragAndDropListComponent } from './abstract-paginated-drag-and-drop-list.component'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { ObjectUpdatesService } from '../../core/data/object-updates/object-updates.service'; +import { ElementRef } from '@angular/core'; +import { Observable } from 'rxjs/internal/Observable'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { RemoteData } from '../../core/data/remote-data'; +import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; +import { createPaginatedList, createSuccessfulRemoteDataObject } from '../testing/utils'; +import { FieldUpdates } from '../../core/data/object-updates/object-updates.reducer'; +import { of as observableOf } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { PaginationComponent } from '../pagination/pagination.component'; + +class MockAbstractPaginatedDragAndDropListComponent extends AbstractPaginatedDragAndDropListComponent { + + constructor(protected objectUpdatesService: ObjectUpdatesService, + protected elRef: ElementRef, + protected mockUrl: string, + protected mockObjectsRD$: Observable>>) { + super(objectUpdatesService, elRef); + } + + initializeObjectsRD(): void { + this.objectsRD$ = this.mockObjectsRD$; + } + + initializeURL(): void { + this.url = this.mockUrl; + } +} + +describe('AbstractPaginatedDragAndDropListComponent', () => { + let component: MockAbstractPaginatedDragAndDropListComponent; + let objectUpdatesService: ObjectUpdatesService; + let elRef: ElementRef; + + const url = 'mock-abstract-paginated-drag-and-drop-list-component'; + + const object1 = Object.assign(new DSpaceObject(), { uuid: 'object-1' }); + const object2 = Object.assign(new DSpaceObject(), { uuid: 'object-2' }); + const objectsRD = createSuccessfulRemoteDataObject(createPaginatedList([object1, object2])); + let objectsRD$: BehaviorSubject>>; + + const updates = { + [object1.uuid]: { field: object1, changeType: undefined }, + [object2.uuid]: { field: object2, changeType: undefined } + } as FieldUpdates; + + let paginationComponent: PaginationComponent; + + beforeEach(() => { + objectUpdatesService = jasmine.createSpyObj('objectUpdatesService', { + initializeWithCustomOrder: {}, + addPageToCustomOrder: {}, + getFieldUpdatesByCustomOrder: observableOf(updates), + saveMoveFieldUpdate: {} + }); + elRef = { + nativeElement: jasmine.createSpyObj('nativeElement', { + querySelector: {} + }) + }; + paginationComponent = jasmine.createSpyObj('paginationComponent', { + doPageChange: {} + }); + objectsRD$ = new BehaviorSubject(objectsRD); + component = new MockAbstractPaginatedDragAndDropListComponent(objectUpdatesService, elRef, url, objectsRD$); + component.paginationComponent = paginationComponent; + component.ngOnInit(); + }); + + it('should call initializeWithCustomOrder to initialize the first page and add it to initializedPages', (done) => { + expect(component.initializedPages.indexOf(0)).toBeLessThan(0); + component.updates$.pipe(take(1)).subscribe(() => { + expect(objectUpdatesService.initializeWithCustomOrder).toHaveBeenCalled(); + expect(component.initializedPages.indexOf(0)).toBeGreaterThanOrEqual(0); + done(); + }); + }); + + it('should initialize the updates correctly', (done) => { + component.updates$.pipe(take(1)).subscribe((fieldUpdates) => { + expect(fieldUpdates).toEqual(updates); + done(); + }); + }); + + describe('when a new page is loaded', () => { + const page = 5; + + beforeEach((done) => { + component.updates$.pipe(take(1)).subscribe(() => { + component.currentPage$.next(page); + objectsRD$.next(objectsRD); + done(); + }); + }); + + it('should call addPageToCustomOrder to initialize the new page and add it to initializedPages', (done) => { + component.updates$.pipe(take(1)).subscribe(() => { + expect(objectUpdatesService.addPageToCustomOrder).toHaveBeenCalled(); + expect(component.initializedPages.indexOf(page - 1)).toBeGreaterThanOrEqual(0); + done(); + }); + }); + + describe('twice', () => { + beforeEach((done) => { + component.updates$.pipe(take(1)).subscribe(() => { + component.currentPage$.next(page); + objectsRD$.next(objectsRD); + done(); + }); + }); + + it('shouldn\'t call addPageToCustomOrder again, as the page has already been initialized', (done) => { + component.updates$.pipe(take(1)).subscribe(() => { + expect(objectUpdatesService.addPageToCustomOrder).toHaveBeenCalledTimes(1); + done(); + }); + }); + }); + }); + + describe('switchPage', () => { + const page = 3; + + beforeEach(() => { + component.switchPage(page); + }); + + it('should set currentPage$ to the new page', () => { + expect(component.currentPage$.value).toEqual(page); + }); + }); + + describe('drop', () => { + const event = { + previousIndex: 0, + currentIndex: 1, + item: { element: { nativeElement: { id: object1.uuid } } } + } as any; + + describe('when the user is hovering over a new page', () => { + const hoverPage = 3; + const hoverElement = { textContent: '' + hoverPage }; + + beforeEach(() => { + elRef.nativeElement.querySelector.and.returnValue(hoverElement); + component.initializedPages.push(hoverPage - 1); + component.drop(event); + }); + + it('should detect the page and set currentPage$ to its value', () => { + expect(component.currentPage$.value).toEqual(hoverPage); + }); + + it('should detect the page and update the pagination component with its value', () => { + expect(paginationComponent.doPageChange).toHaveBeenCalledWith(hoverPage); + }); + + it('should send out a saveMoveFieldUpdate with the correct values', () => { + expect(objectUpdatesService.saveMoveFieldUpdate).toHaveBeenCalledWith(url, event.previousIndex, 0, 0, hoverPage - 1, object1); + }); + }); + + describe('when the user is not hovering over a new page', () => { + beforeEach(() => { + component.drop(event); + }); + + it('should send out a saveMoveFieldUpdate with the correct values', () => { + expect(objectUpdatesService.saveMoveFieldUpdate).toHaveBeenCalledWith(url, event.previousIndex, event.currentIndex, 0, 0); + }); + }); + }); +}); diff --git a/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts new file mode 100644 index 0000000000..a34b5d5bc0 --- /dev/null +++ b/src/app/shared/pagination-drag-and-drop/abstract-paginated-drag-and-drop-list.component.ts @@ -0,0 +1,195 @@ +import { FieldUpdates } from '../../core/data/object-updates/object-updates.reducer'; +import { Observable } from 'rxjs/internal/Observable'; +import { RemoteData } from '../../core/data/remote-data'; +import { PaginatedList } from '../../core/data/paginated-list'; +import { PaginationComponentOptions } from '../pagination/pagination-component-options.model'; +import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject'; +import { ObjectUpdatesService } from '../../core/data/object-updates/object-updates.service'; +import { switchMap, take, tap } from 'rxjs/operators'; +import { hasValue, isEmpty, isNotEmpty } from '../empty.util'; +import { paginatedListToArray } from '../../core/shared/operators'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; +import { CdkDragDrop } from '@angular/cdk/drag-drop'; +import { ElementRef, ViewChild } from '@angular/core'; +import { PaginationComponent } from '../pagination/pagination.component'; + +/** + * An abstract component containing general methods and logic to be able to drag and drop objects within a paginated + * list. This implementation supports being able to drag and drop objects between pages. + * Dragging an object on top of a page number will automatically detect the page it's being dropped on, send an update + * to the store and add the object on top of that page. + * + * To extend this component, it is important to make sure to: + * - Initialize objectsRD$ within the initializeObjectsRD() method + * - Initialize a unique URL for this component/page within the initializeURL() method + * - Add (cdkDropListDropped)="drop($event)" to the cdkDropList element in your template + * - Add (pageChange)="switchPage($event)" to the ds-pagination element in your template + * - Use the updates$ observable for building your list of cdkDrag elements in your template + * + * An example component extending from this abstract component: PaginatedDragAndDropBitstreamListComponent + */ +export abstract class AbstractPaginatedDragAndDropListComponent { + /** + * A view on the child pagination component + */ + @ViewChild(PaginationComponent, {static: false}) paginationComponent: PaginationComponent; + + /** + * The URL to use for accessing the object updates from this list + */ + url: string; + + /** + * The objects to retrieve data for and transform into field updates + */ + objectsRD$: Observable>>; + + /** + * The updates to the current list + */ + updates$: Observable; + + /** + * The amount of objects to display per page + */ + pageSize = 10; + + /** + * The page options to use for fetching the objects + * Start at page 1 and always use the set page size + */ + options = Object.assign(new PaginationComponentOptions(),{ + id: 'paginated-drag-and-drop-options', + currentPage: 1, + pageSize: this.pageSize + }); + + /** + * The current page being displayed + */ + currentPage$ = new BehaviorSubject(1); + + /** + * A list of pages that have been initialized in the field-update store + */ + initializedPages: number[] = []; + + /** + * An object storing information about an update that should be fired whenever fireToUpdate is called + */ + toUpdate: { + fromIndex: number, + toIndex: number, + fromPage: number, + toPage: number, + field?: T + }; + + protected constructor(protected objectUpdatesService: ObjectUpdatesService, + protected elRef: ElementRef) { + } + + /** + * Initialize the observables + */ + ngOnInit() { + this.initializeObjectsRD(); + this.initializeURL(); + this.initializeUpdates(); + } + + /** + * Overwrite this method to define how the list of objects is initialized and updated + */ + abstract initializeObjectsRD(): void; + + /** + * Overwrite this method to define how the URL is set + */ + abstract initializeURL(): void; + + /** + * Initialize the field-updates in the store + * This method ensures (new) pages displayed are automatically added to the field-update store when the objectsRD updates + */ + initializeUpdates(): void { + this.updates$ = this.objectsRD$.pipe( + paginatedListToArray(), + tap((objects: T[]) => { + // Pages in the field-update store are indexed starting at 0 (because they're stored in an array of pages) + const updatesPage = this.currentPage$.value - 1; + if (isEmpty(this.initializedPages)) { + // No updates have been initialized yet for this list, initialize the first page + this.objectUpdatesService.initializeWithCustomOrder(this.url, objects, new Date(), this.pageSize, updatesPage); + this.initializedPages.push(updatesPage); + } else if (this.initializedPages.indexOf(updatesPage) < 0) { + // Updates were initialized for this list, but not the page we're on. Add the current page to the field-update store for this list + this.objectUpdatesService.addPageToCustomOrder(this.url, objects, updatesPage); + this.initializedPages.push(updatesPage); + } + + // The new page is loaded into the store, check if there are any updates waiting and fire those as well + this.fireToUpdate(); + }), + switchMap((objects: T[]) => this.objectUpdatesService.getFieldUpdatesByCustomOrder(this.url, objects, this.currentPage$.value - 1)) + ); + } + + /** + * Update the current page + * @param page + */ + switchPage(page: number) { + this.currentPage$.next(page); + } + + /** + * An object was moved, send updates to the store. + * When the object is dropped on a page within the pagination of this component, the object moves to the top of that + * page and the pagination automatically loads and switches the view to that page. + * @param event + */ + drop(event: CdkDragDrop) { + // Check if the user is hovering over any of the pagination's pages at the time of dropping the object + const droppedOnElement = this.elRef.nativeElement.querySelector('.page-item:hover'); + if (hasValue(droppedOnElement) && hasValue(droppedOnElement.textContent)) { + // The user is hovering over a page, fetch the page's number from the element + const page = Number(droppedOnElement.textContent); + if (hasValue(page) && !Number.isNaN(page)) { + const id = event.item.element.nativeElement.id; + this.updates$.pipe(take(1)).subscribe((updates: FieldUpdates) => { + const field = hasValue(updates[id]) ? updates[id].field : undefined; + this.toUpdate = Object.assign({ + fromIndex: event.previousIndex, + toIndex: 0, + fromPage: this.currentPage$.value - 1, + toPage: page - 1, + field + }); + // Switch to the dropped-on page and force a page update for the pagination component + this.currentPage$.next(page); + this.paginationComponent.doPageChange(page); + if (this.initializedPages.indexOf(page - 1) >= 0) { + // The page the object is being dropped to has already been loaded before, directly fire an update to the store. + // For pages that haven't been loaded before, the updates$ observable will call fireToUpdate after the new page + // has loaded + this.fireToUpdate(); + } + }); + } + } else { + this.objectUpdatesService.saveMoveFieldUpdate(this.url, event.previousIndex, event.currentIndex, this.currentPage$.value - 1, this.currentPage$.value - 1); + } + } + + /** + * Method checking if there's an update ready to be fired. Send out a MoveFieldUpdate to the store if there's an + * update present and clear the update afterwards. + */ + fireToUpdate() { + if (hasValue(this.toUpdate)) { + this.objectUpdatesService.saveMoveFieldUpdate(this.url, this.toUpdate.fromIndex, this.toUpdate.toIndex, this.toUpdate.fromPage, this.toUpdate.toPage, this.toUpdate.field); + this.toUpdate = undefined; + } + } +} diff --git a/src/app/shared/pagination/pagination.component.html b/src/app/shared/pagination/pagination.component.html index c16a153026..649fe686ff 100644 --- a/src/app/shared/pagination/pagination.component.html +++ b/src/app/shared/pagination/pagination.component.html @@ -1,5 +1,5 @@
-
+
{{ 'pagination.showing.label' | translate }} diff --git a/src/app/shared/pagination/pagination.component.spec.ts b/src/app/shared/pagination/pagination.component.spec.ts index dfbef9123a..949ebc85d3 100644 --- a/src/app/shared/pagination/pagination.component.spec.ts +++ b/src/app/shared/pagination/pagination.component.spec.ts @@ -139,7 +139,7 @@ describe('Pagination component', () => { } }), NgxPaginationModule, - NgbModule.forRoot(), + NgbModule, RouterTestingModule.withRoutes([ { path: 'home', component: TestComponent } ])], 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/responsive-table-sizes/responsive-column-sizes.spec.ts b/src/app/shared/responsive-table-sizes/responsive-column-sizes.spec.ts new file mode 100644 index 0000000000..843d0f043e --- /dev/null +++ b/src/app/shared/responsive-table-sizes/responsive-column-sizes.spec.ts @@ -0,0 +1,22 @@ +import { ResponsiveColumnSizes } from './responsive-column-sizes'; + +describe('ResponsiveColumnSizes', () => { + const xs = 2; + const sm = 3; + const md = 4; + const lg = 6; + const xl = 8; + const column = new ResponsiveColumnSizes(xs, sm, md, lg, xl); + + describe('buildClasses', () => { + let classes: string; + + beforeEach(() => { + classes = column.buildClasses(); + }); + + it('should return the correct bootstrap classes', () => { + expect(classes).toEqual(`col-${xs} col-sm-${sm} col-md-${md} col-lg-${lg} col-xl-${xl}`); + }); + }); +}); diff --git a/src/app/shared/responsive-table-sizes/responsive-column-sizes.ts b/src/app/shared/responsive-table-sizes/responsive-column-sizes.ts new file mode 100644 index 0000000000..84651f3ef5 --- /dev/null +++ b/src/app/shared/responsive-table-sizes/responsive-column-sizes.ts @@ -0,0 +1,46 @@ +/** + * A helper class storing the sizes in which to render a single column + * The values in this class are expected to be between 1 and 12 + * There are used to be added to bootstrap classes such as col-xs-{this.xs} + */ +export class ResponsiveColumnSizes { + /** + * The extra small bootstrap size + */ + xs: number; + + /** + * The small bootstrap size + */ + sm: number; + + /** + * The medium bootstrap size + */ + md: number; + + /** + * The large bootstrap size + */ + lg: number; + + /** + * The extra large bootstrap size + */ + xl: number; + + constructor(xs: number, sm: number, md: number, lg: number, xl: number) { + this.xs = xs; + this.sm = sm; + this.md = md; + this.lg = lg; + this.xl = xl; + } + + /** + * Build the bootstrap responsive column classes matching the values of this object + */ + buildClasses(): string { + return `col-${this.xs} col-sm-${this.sm} col-md-${this.md} col-lg-${this.lg} col-xl-${this.xl}` + } +} diff --git a/src/app/shared/responsive-table-sizes/responsive-table-sizes.spec.ts b/src/app/shared/responsive-table-sizes/responsive-table-sizes.spec.ts new file mode 100644 index 0000000000..23df9b1c25 --- /dev/null +++ b/src/app/shared/responsive-table-sizes/responsive-table-sizes.spec.ts @@ -0,0 +1,76 @@ +import { ResponsiveColumnSizes } from './responsive-column-sizes'; +import { ResponsiveTableSizes } from './responsive-table-sizes'; + +describe('ResponsiveColumnSizes', () => { + const column0 = new ResponsiveColumnSizes(2, 3, 4, 6, 8); + const column1 = new ResponsiveColumnSizes(8, 7, 4, 2, 1); + const column2 = new ResponsiveColumnSizes(1, 1, 4, 2, 1); + const column3 = new ResponsiveColumnSizes(1, 1, 4, 2, 2); + const table = new ResponsiveTableSizes([column0, column1, column2, column3]); + + describe('combineColumns', () => { + describe('when start value is out of bounds', () => { + let combined: ResponsiveColumnSizes; + + beforeEach(() => { + combined = table.combineColumns(-1, 2); + }); + + it('should return undefined', () => { + expect(combined).toBeUndefined(); + }); + }); + + describe('when end value is out of bounds', () => { + let combined: ResponsiveColumnSizes; + + beforeEach(() => { + combined = table.combineColumns(0, 5); + }); + + it('should return undefined', () => { + expect(combined).toBeUndefined(); + }); + }); + + describe('when start value is greater than end value', () => { + let combined: ResponsiveColumnSizes; + + beforeEach(() => { + combined = table.combineColumns(2, 0); + }); + + it('should return undefined', () => { + expect(combined).toBeUndefined(); + }); + }); + + describe('when start value is equal to end value', () => { + let combined: ResponsiveColumnSizes; + + beforeEach(() => { + combined = table.combineColumns(0, 0); + }); + + it('should return undefined', () => { + expect(combined).toBeUndefined(); + }); + }); + + describe('when provided with valid values', () => { + let combined: ResponsiveColumnSizes; + + beforeEach(() => { + combined = table.combineColumns(0, 2); + }); + + it('should combine the sizes of each column within the range into one', () => { + expect(combined.xs).toEqual(column0.xs + column1.xs + column2.xs); + expect(combined.sm).toEqual(column0.sm + column1.sm + column2.sm); + expect(combined.md).toEqual(column0.md + column1.md + column2.md); + expect(combined.lg).toEqual(column0.lg + column1.lg + column2.lg); + expect(combined.xl).toEqual(column0.xl + column1.xl + column2.xl); + }); + }); + }); +}); diff --git a/src/app/shared/responsive-table-sizes/responsive-table-sizes.ts b/src/app/shared/responsive-table-sizes/responsive-table-sizes.ts new file mode 100644 index 0000000000..b68774d46f --- /dev/null +++ b/src/app/shared/responsive-table-sizes/responsive-table-sizes.ts @@ -0,0 +1,42 @@ +import { ResponsiveColumnSizes } from './responsive-column-sizes'; +import { hasValue } from '../empty.util'; + +/** + * A helper class storing the sizes in which to render a table + * It stores a list of columns, which in turn store their own bootstrap column sizes + */ +export class ResponsiveTableSizes { + /** + * A list of all the columns and their responsive sizes within this table + */ + columns: ResponsiveColumnSizes[]; + + constructor(columns: ResponsiveColumnSizes[]) { + this.columns = columns; + } + + /** + * Combine the values of multiple columns into a single ResponsiveColumnSizes + * Useful when a row element stretches over multiple columns + * @param start Index of the first column + * @param end Index of the last column (inclusive) + */ + combineColumns(start: number, end: number): ResponsiveColumnSizes { + if (start < end && hasValue(this.columns[start]) && hasValue(this.columns[end])) { + let xs = this.columns[start].xs; + let sm = this.columns[start].sm; + let md = this.columns[start].md; + let lg = this.columns[start].lg; + let xl = this.columns[start].xl; + for (let i = start + 1; i < end + 1; i++) { + xs += this.columns[i].xs; + sm += this.columns[i].sm; + md += this.columns[i].md; + lg += this.columns[i].lg; + xl += this.columns[i].xl; + } + return new ResponsiveColumnSizes(xs, sm, md, lg, xl); + } + return undefined; + } +} diff --git a/src/app/shared/search-form/search-form.component.spec.ts b/src/app/shared/search-form/search-form.component.spec.ts index 74ed4bb913..ffd8dd87a2 100644 --- a/src/app/shared/search-form/search-form.component.spec.ts +++ b/src/app/shared/search-form/search-form.component.spec.ts @@ -124,7 +124,11 @@ export const objects: DSpaceObject[] = [ scheduler: null } }, - self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/communities/7669c72a-3f2a-451f-a3b9-9210e7a4c02f', + _links: { + self: { + href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/communities/7669c72a-3f2a-451f-a3b9-9210e7a4c02f', + }, + }, id: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', uuid: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', type: Community.type, @@ -178,7 +182,11 @@ export const objects: DSpaceObject[] = [ scheduler: null } }, - self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/communities/9076bd16-e69a-48d6-9e41-0238cb40d863', + _links: { + self: { + href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/communities/9076bd16-e69a-48d6-9e41-0238cb40d863', + }, + }, id: '9076bd16-e69a-48d6-9e41-0238cb40d863', uuid: '9076bd16-e69a-48d6-9e41-0238cb40d863', type: Community.type, diff --git a/src/app/shared/search-form/search-form.component.ts b/src/app/shared/search-form/search-form.component.ts index c49353deb3..97541c4786 100644 --- a/src/app/shared/search-form/search-form.component.ts +++ b/src/app/shared/search-form/search-form.component.ts @@ -1,9 +1,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { Router } from '@angular/router'; -import { hasValue, isNotEmpty } from '../empty.util'; -import { QueryParamsHandling } from '@angular/router/src/config'; -import { MYDSPACE_ROUTE } from '../../+my-dspace-page/my-dspace-page.component'; +import { isNotEmpty } from '../empty.util'; import { SearchService } from '../../core/shared/search/search.service'; import { currentPath } from '../utils/route.utils'; diff --git a/src/app/shared/search/facet-value.model.ts b/src/app/shared/search/facet-value.model.ts index d2cc521356..032f9c9b2a 100644 --- a/src/app/shared/search/facet-value.model.ts +++ b/src/app/shared/search/facet-value.model.ts @@ -1,9 +1,11 @@ -import { autoserialize, autoserializeAs } from 'cerialize'; +import { autoserialize, autoserializeAs, deserialize } from 'cerialize'; +import { HALLink } from '../../core/shared/hal-link.model'; +import { HALResource } from '../../core/shared/hal-resource.model'; /** * Class representing possible values for a certain filter */ -export class FacetValue { +export class FacetValue implements HALResource { /** * The display label of the facet value */ @@ -23,8 +25,11 @@ export class FacetValue { count: number; /** - * The REST url to add this filter value + * The {@link HALLink}s for this FacetValue */ - @autoserialize - search: string; + @deserialize + _links: { + self: HALLink + search: HALLink + } } diff --git a/src/app/shared/search/normalized-search-result.model.ts b/src/app/shared/search/normalized-search-result.model.ts deleted file mode 100644 index 3904b4a494..0000000000 --- a/src/app/shared/search/normalized-search-result.model.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { autoserialize, inheritSerialization } from 'cerialize'; -import { MetadataMap } from '../../core/shared/metadata.models'; -import { NormalizedObject } from '../../core/cache/models/normalized-object.model'; - -/** - * Represents a normalized version of a search result object of a certain DSpaceObject - */ -@inheritSerialization(NormalizedObject) -export class NormalizedSearchResult { - /** - * The UUID of the DSpaceObject that was found - */ - @autoserialize - indexableObject: string; - - /** - * The metadata that was used to find this item, hithighlighted - */ - @autoserialize - hitHighlights: MetadataMap; -} 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-authority-filter/search-authority-filter.component.ts b/src/app/shared/search/search-filters/search-filter/search-authority-filter/search-authority-filter.component.ts index 99e9bfac2e..5dc930f67f 100644 --- a/src/app/shared/search/search-filters/search-filter/search-authority-filter/search-authority-filter.component.ts +++ b/src/app/shared/search/search-filters/search-filter/search-authority-filter/search-authority-filter.component.ts @@ -23,7 +23,7 @@ export class SearchAuthorityFilterComponent extends SearchFacetFilterComponent i * Retrieve facet value from search link */ protected getFacetValue(facet: FacetValue): string { - const search = facet.search; + const search = facet._links.search.href; const hashes = search.slice(search.indexOf('?') + 1).split('&'); const params = {}; hashes.map((hash) => { 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-option/search-facet-option.component.spec.ts b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.spec.ts index ff5db664db..43f47cc2b9 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.spec.ts +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.spec.ts @@ -1,20 +1,20 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { SearchFacetOptionComponent } from './search-facet-option.component'; -import { SearchFilterConfig } from '../../../../search-filter-config.model'; -import { FilterType } from '../../../../filter-type.model'; -import { FacetValue } from '../../../../facet-value.model'; import { FormsModule } from '@angular/forms'; -import { of as observableOf } from 'rxjs'; -import { SearchService } from '../../../../../../core/shared/search/search.service'; -import { SearchServiceStub } from '../../../../../testing/search-service-stub'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { Router } from '@angular/router'; -import { RouterStub } from '../../../../../testing/router-stub'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; import { SearchConfigurationService } from '../../../../../../core/shared/search/search-configuration.service'; import { SearchFilterService } from '../../../../../../core/shared/search/search-filter.service'; -import { By } from '@angular/platform-browser'; +import { SearchService } from '../../../../../../core/shared/search/search.service'; +import { RouterStub } from '../../../../../testing/router-stub'; +import { SearchServiceStub } from '../../../../../testing/search-service-stub'; +import { FacetValue } from '../../../../facet-value.model'; +import { FilterType } from '../../../../filter-type.model'; +import { SearchFilterConfig } from '../../../../search-filter-config.model'; +import { SearchFacetOptionComponent } from './search-facet-option.component'; describe('SearchFacetOptionComponent', () => { let comp: SearchFacetOptionComponent; @@ -47,21 +47,30 @@ describe('SearchFacetOptionComponent', () => { label: value2, value: value2, count: 20, - search: `` + _links: { + self: { href: 'selectedValue-self-link2' }, + search: { href: `` } + } }; const selectedValue: FacetValue = { label: value1, value: value1, count: 20, - search: `http://test.org/api/discover/search/objects?f.${filterName1}=${value1},${operator}` + _links: { + self: { href: 'selectedValue-self-link1' }, + search: { href: `http://test.org/api/discover/search/objects?f.${filterName1}=${value1},${operator}` } + } }; const authorityValue: FacetValue = { label: value2, value: value2, count: 20, - search: `http://test.org/api/discover/search/objects?f.${filterName2}=${value2},${operator}` + _links: { + self: { href: 'authorityValue-self-link2' }, + search: { href: `http://test.org/api/discover/search/objects?f.${filterName2}=${value2},${operator}` } + } }; const searchLink = '/search'; diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.ts b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.ts index 512cd5501c..04e810edda 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.ts +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-option/search-facet-option.component.ts @@ -113,7 +113,7 @@ export class SearchFacetOptionComponent implements OnInit, OnDestroy { */ private getFacetValue(): string { if (this.filterConfig.type === FilterType.authority) { - const search = this.filterValue.search; + const search = this.filterValue._links.search.href; const hashes = search.slice(search.indexOf('?') + 1).split('&'); const params = {}; hashes.map((hash) => { diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.spec.ts b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.spec.ts index e6878dadd1..34fb64040c 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.spec.ts +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-range-option/search-facet-range-option.component.spec.ts @@ -38,7 +38,14 @@ describe('SearchFacetRangeOptionComponent', () => { label: value2, value: value2, count: 20, - search: '' + _links: { + self: { + href: '' + }, + search: { + href: '' + } + } }; const searchLink = '/search'; @@ -96,7 +103,14 @@ describe('SearchFacetRangeOptionComponent', () => { label: '50-60', value: '50-60', count: 20, - search: '' + _links: { + self: { + href: '' + }, + search: { + href: '' + } + } }; (comp as any).updateChangeParams(); expect(comp.changeQueryParams).toEqual({ 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-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.spec.ts b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.spec.ts index 4ea6571a87..cfeda7d51c 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.spec.ts +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.spec.ts @@ -1,19 +1,19 @@ import { ChangeDetectionStrategy, NO_ERRORS_SCHEMA } from '@angular/core'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { SearchFilterConfig } from '../../../../search-filter-config.model'; -import { FilterType } from '../../../../filter-type.model'; import { FormsModule } from '@angular/forms'; -import { of as observableOf } from 'rxjs'; -import { SearchService } from '../../../../../../core/shared/search/search.service'; -import { SearchServiceStub } from '../../../../../testing/search-service-stub'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { Router } from '@angular/router'; -import { RouterStub } from '../../../../../testing/router-stub'; +import { TranslateModule } from '@ngx-translate/core'; +import { of as observableOf } from 'rxjs'; import { SearchConfigurationService } from '../../../../../../core/shared/search/search-configuration.service'; import { SearchFilterService } from '../../../../../../core/shared/search/search-filter.service'; -import { SearchFacetSelectedOptionComponent } from './search-facet-selected-option.component'; +import { SearchService } from '../../../../../../core/shared/search/search.service'; +import { RouterStub } from '../../../../../testing/router-stub'; +import { SearchServiceStub } from '../../../../../testing/search-service-stub'; import { FacetValue } from '../../../../facet-value.model'; +import { FilterType } from '../../../../filter-type.model'; +import { SearchFilterConfig } from '../../../../search-filter-config.model'; +import { SearchFacetSelectedOptionComponent } from './search-facet-selected-option.component'; describe('SearchFacetSelectedOptionComponent', () => { let comp: SearchFacetSelectedOptionComponent; @@ -47,25 +47,37 @@ describe('SearchFacetSelectedOptionComponent', () => { label: value1, value: value1, count: 20, - search: `http://test.org/api/discover/search/objects?f.${filterName1}=${value1}` + _links: { + self: { href: 'selectedValue-self-link1' }, + search: { href: `http://test.org/api/discover/search/objects?f.${filterName1}=${value1}` } + } }; const selectedValue2: FacetValue = { label: value2, value: value2, count: 20, - search: `http://test.org/api/discover/search/objects?f.${filterName1}=${value2}` + _links: { + self: { href: 'selectedValue-self-link2' }, + search: { href: `http://test.org/api/discover/search/objects?f.${filterName1}=${value2}` } + } }; const selectedAuthorityValue: FacetValue = { label: label1, value: value1, count: 20, - search: `http://test.org/api/discover/search/objects?f.${filterName2}=${value1},${operator}` + _links: { + self: { href: 'selectedAuthorityValue-self-link1' }, + search: { href: `http://test.org/api/discover/search/objects?f.${filterName2}=${value1},${operator}` } + } }; const selectedAuthorityValue2: FacetValue = { label: label2, value: value2, count: 20, - search: `http://test.org/api/discover/search/objects?f.${filterName2}=${value2},${operator}` + _links: { + self: { href: 'selectedAuthorityValue-self-link2' }, + search: { href: `http://test.org/api/discover/search/objects?f.${filterName2}=${value2},${operator}` } + } }; const selectedValues = [selectedValue, selectedValue2]; const selectedAuthorityValues = [selectedAuthorityValue, selectedAuthorityValue2]; @@ -73,13 +85,19 @@ describe('SearchFacetSelectedOptionComponent', () => { label: value2, value: value2, count: 1, - search: '' + _links: { + self: { href: 'facetValue-self-link2' }, + search: { href: `` } + } }; const authorityValue: FacetValue = { label: label2, value: value2, count: 20, - search: `http://test.org/api/discover/search/objects?f.${filterName2}=${value2},${operator}` + _links: { + self: { href: 'authorityValue-self-link2' }, + search: { href: `http://test.org/api/discover/search/objects?f.${filterName2}=${value2},${operator}` } + } }; const selectedValues$ = observableOf(selectedValues); const selectedAuthorityValues$ = observableOf(selectedAuthorityValues); diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.ts b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.ts index 0cf54d88f5..f58a903b0c 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.ts +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter-options/search-facet-selected-option/search-facet-selected-option.component.ts @@ -102,7 +102,7 @@ export class SearchFacetSelectedOptionComponent implements OnInit, OnDestroy { */ private getFacetValue(facetValue: FacetValue): string { if (this.filterConfig.type === FilterType.authority) { - const search = facetValue.search; + const search = facetValue._links.search.href; const hashes = search.slice(search.indexOf('?') + 1).split('&'); const params = {}; hashes.map((hash) => { diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts b/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts index 1b66e29246..7695497750 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.spec.ts @@ -39,17 +39,38 @@ describe('SearchFacetFilterComponent', () => { label: value1, value: value1, count: 52, - search: '' + _links: { + self: { + href: '' + }, + search: { + href: '' + } + } }, { label: value2, value: value2, count: 20, - search: '' + _links: { + self: { + href: '' + }, + search: { + href: '' + } + } }, { label: value3, value: value3, count: 5, - search: '' + _links: { + self: { + href: '' + }, + search: { + href: '' + } + } } ]; diff --git a/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts b/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts index 1d6a85b95b..df0c53f543 100644 --- a/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts +++ b/src/app/shared/search/search-filters/search-filter/search-facet-filter/search-facet-filter.component.ts @@ -320,7 +320,7 @@ export class SearchFacetFilterComponent implements OnInit, OnDestroy { * Prevent unnecessary rerendering */ trackUpdate(index, value: FacetValue) { - return value ? value.search : undefined; + return value ? value._links.search.href : undefined; } } diff --git a/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts b/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts index 530b4e6b71..72840b3ffe 100644 --- a/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts +++ b/src/app/shared/search/search-filters/search-filter/search-range-filter/search-range-filter.component.spec.ts @@ -45,17 +45,38 @@ describe('SearchRangeFilterComponent', () => { label: value1, value: value1, count: 52, - search: '' + _links: { + self: { + href:'' + }, + search: { + href: '' + } + } }, { label: value2, value: value2, count: 20, - search: '' + _links: { + self: { + href:'' + }, + search: { + href: '' + } + } }, { label: value3, value: value3, count: 5, - search: '' + _links: { + self: { + href:'' + }, + search: { + href: '' + } + } } ]; 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 4dc92c32f4..7d5f4dd207 100644 --- a/src/app/shared/search/search-options.model.ts +++ b/src/app/shared/search/search-options.model.ts @@ -1,6 +1,5 @@ import { isNotEmpty } from '../empty.util'; import { URLCombiner } from '../../core/url-combiner/url-combiner'; -import 'core-js/library/fn/object/entries'; import { SearchFilter } from './search-filter.model'; import { DSpaceObjectType } from '../../core/shared/dspace-object-type.model'; import { ViewMode } from '../../core/shared/view-mode.model'; @@ -51,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/search/search-query-response.model.ts b/src/app/shared/search/search-query-response.model.ts index da15a60631..2c9d11e2b3 100644 --- a/src/app/shared/search/search-query-response.model.ts +++ b/src/app/shared/search/search-query-response.model.ts @@ -1,6 +1,7 @@ import { autoserialize, autoserializeAs } from 'cerialize'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { PageInfo } from '../../core/shared/page-info.model'; -import { NormalizedSearchResult } from './normalized-search-result.model'; +import { SearchResult } from './search-result.model'; /** * Class representing the response returned by the server when performing a search request @@ -51,8 +52,8 @@ export class SearchQueryResponse { /** * The results for this query */ - @autoserializeAs(NormalizedSearchResult) - objects: NormalizedSearchResult[]; + @autoserializeAs(SearchResult) + objects: Array>; @autoserialize facets: any; // TODO diff --git a/src/app/shared/search/search-result.model.ts b/src/app/shared/search/search-result.model.ts index 8d26395021..2f14a60c97 100644 --- a/src/app/shared/search/search-result.model.ts +++ b/src/app/shared/search/search-result.model.ts @@ -1,25 +1,40 @@ +import { autoserialize, deserialize } from 'cerialize'; +import { link } from '../../core/cache/builders/build-decorators'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; -import { MetadataMap } from '../../core/shared/metadata.models'; -import { ListableObject } from '../object-collection/shared/listable-object.model'; -import { excludeFromEquals, fieldsForEquals } from '../../core/utilities/equals.decorators'; +import { DSPACE_OBJECT } from '../../core/shared/dspace-object.resource-type'; import { GenericConstructor } from '../../core/shared/generic-constructor'; +import { HALLink } from '../../core/shared/hal-link.model'; +import { MetadataMap } from '../../core/shared/metadata.models'; +import { excludeFromEquals, fieldsForEquals } from '../../core/utilities/equals.decorators'; +import { ListableObject } from '../object-collection/shared/listable-object.model'; /** * Represents a search result object of a certain () DSpaceObject */ export class SearchResult extends ListableObject { - /** - * The DSpaceObject that was found - */ - @fieldsForEquals('uuid') - indexableObject: T; - /** * The metadata that was used to find this item, hithighlighted */ @excludeFromEquals + @autoserialize hitHighlights: MetadataMap; + /** + * The {@link HALLink}s for this SearchResult + */ + @deserialize + _links: { + self: HALLink; + indexableObject: HALLink; + }; + + /** + * The DSpaceObject that was found + */ + @fieldsForEquals('uuid') + @link(DSPACE_OBJECT) + indexableObject: T; + /** * Method that returns as which type of object this object should be rendered */ diff --git a/src/app/shared/search/search-results/search-results.component.html b/src/app/shared/search/search-results/search-results.component.html index ab1e96c58f..cbc56d1080 100644 --- a/src/app/shared/search/search-results/search-results.component.html +++ b/src/app/shared/search/search-results/search-results.component.html @@ -8,6 +8,7 @@ [selectable]="selectable" [selectionConfig]="selectionConfig" [context]="context" + [hidePaginationDetail]="hidePaginationDetail" (deselectObject)="deselectObject.emit($event)" (selectObject)="selectObject.emit($event)" > diff --git a/src/app/shared/search/search-results/search-results.component.spec.ts b/src/app/shared/search/search-results/search-results.component.spec.ts index d2c02717c9..60e91d6fc1 100644 --- a/src/app/shared/search/search-results/search-results.component.spec.ts +++ b/src/app/shared/search/search-results/search-results.component.spec.ts @@ -111,7 +111,11 @@ export const objects = [ scheduler: null } }, - self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/communities/7669c72a-3f2a-451f-a3b9-9210e7a4c02f', + _links: { + self: { + href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/communities/7669c72a-3f2a-451f-a3b9-9210e7a4c02f', + }, + }, id: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', uuid: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', type: Community.type, @@ -165,7 +169,11 @@ export const objects = [ scheduler: null } }, - self: 'https://dspace7.4science.it/dspace-spring-rest/api/core/communities/9076bd16-e69a-48d6-9e41-0238cb40d863', + _links: { + self: { + href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/communities/9076bd16-e69a-48d6-9e41-0238cb40d863', + }, + }, id: '9076bd16-e69a-48d6-9e41-0238cb40d863', uuid: '9076bd16-e69a-48d6-9e41-0238cb40d863', type: Community.type, diff --git a/src/app/shared/search/search-results/search-results.component.ts b/src/app/shared/search/search-results/search-results.component.ts index f245b5f9ae..b094e69a57 100644 --- a/src/app/shared/search/search-results/search-results.component.ts +++ b/src/app/shared/search/search-results/search-results.component.ts @@ -67,6 +67,11 @@ export class SearchResultsComponent { @Input() context: Context; + /** + * Option for hiding the pagination detail + */ + @Input() hidePaginationDetail = false; + @Input() selectionConfig: {repeatable: boolean, listId: string}; @Output() deselectObject: EventEmitter = new EventEmitter(); diff --git a/src/app/shared/search/search-sidebar/search-sidebar.component.spec.ts b/src/app/shared/search/search-sidebar/search-sidebar.component.spec.ts index 6586254227..f05b33ff88 100644 --- a/src/app/shared/search/search-sidebar/search-sidebar.component.spec.ts +++ b/src/app/shared/search/search-sidebar/search-sidebar.component.spec.ts @@ -12,7 +12,7 @@ describe('SearchSidebarComponent', () => { // async beforeEach beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot(), NgbCollapseModule.forRoot()], + imports: [TranslateModule.forRoot(), NgbCollapseModule], declarations: [SearchSidebarComponent], schemas: [NO_ERRORS_SCHEMA], }) diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 89157aeee1..c053c8732c 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -6,9 +6,10 @@ 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 { ComcolRoleComponent } from './comcol-forms/edit-comcol-page/comcol-role/comcol-role.component'; import { PublicationListElementComponent } from './object-list/item-list-element/item-types/publication/publication-list-element.component'; import { FileUploadModule } from 'ng2-file-upload'; @@ -42,13 +43,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'; @@ -66,7 +69,6 @@ import { DsDynamicFormGroupComponent } from './form/builder/ds-dynamic-form-ui/m import { DsDynamicFormArrayComponent } from './form/builder/ds-dynamic-form-ui/models/array-group/dynamic-form-array.component'; import { DsDynamicRelationGroupComponent } from './form/builder/ds-dynamic-form-ui/models/relation-group/dynamic-relation-group.components'; import { DsDatePickerInlineComponent } from './form/builder/ds-dynamic-form-ui/models/date-picker-inline/dynamic-date-picker-inline.component'; -import { SortablejsModule } from 'angular-sortablejs'; import { NumberPickerComponent } from './number-picker/number-picker.component'; import { DsDatePickerComponent } from './form/builder/ds-dynamic-form-ui/models/date-picker/date-picker.component'; import { DsDynamicLookupComponent } from './form/builder/ds-dynamic-form-ui/models/lookup/dynamic-lookup.component'; @@ -173,8 +175,23 @@ import { SidebarFilterComponent } from './sidebar/filter/sidebar-filter.componen import { SidebarFilterSelectedOptionComponent } from './sidebar/filter/sidebar-filter-selected-option.component'; import { SelectableListItemControlComponent } from './object-collection/shared/selectable-list-item-control/selectable-list-item-control.component'; import { DsDynamicLookupRelationExternalSourceTabComponent } from './form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/dynamic-lookup-relation-external-source-tab.component'; +import { ExternalSourceEntryImportModalComponent } from './form/builder/ds-dynamic-form-ui/relation-lookup-modal/external-source-tab/external-source-entry-import-modal/external-source-entry-import-modal.component'; +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 { CustomSwitchComponent } from './form/builder/ds-dynamic-form-ui/models/custom-switch/custom-switch.component'; +import { BundleListElementComponent } from './object-list/bundle-list-element/bundle-list-element.component'; +import { MissingTranslationHelper } from './translate/missing-translation.helper'; +import { ItemVersionsNoticeComponent } from './item/item-versions/notice/item-versions-notice.component'; +import { ClaimedTaskActionsLoaderComponent } from './mydspace-actions/claimed-task/switcher/claimed-task-actions-loader.component'; +import { ClaimedTaskActionsDirective } from './mydspace-actions/claimed-task/switcher/claimed-task-actions.directive'; +import { ClaimedTaskActionsEditMetadataComponent } from './mydspace-actions/claimed-task/edit-metadata/claimed-task-actions-edit-metadata.component'; const MODULES = [ // Do NOT include UniversalModule, HttpModule, or JsonpModule here @@ -192,7 +209,6 @@ const MODULES = [ NgxPaginationModule, ReactiveFormsModule, RouterModule, - TranslateModule, NouisliderModule, MomentModule, TextMaskModule, @@ -201,7 +217,11 @@ const MODULES = [ ]; const ROOT_MODULES = [ - TooltipModule.forRoot() + TranslateModule.forChild({ + missingTranslationHandler: { provide: MissingTranslationHandler, useClass: MissingTranslationHelper }, + useDefaultLang: true + }), + TooltipModule.forRoot(), ]; const PIPES = [ @@ -233,6 +253,7 @@ const COMPONENTS = [ EditComColPageComponent, DeleteComColPageComponent, ComcolPageBrowseByComponent, + ComcolRoleComponent, DsDynamicFormComponent, DsDynamicFormControlContainerComponent, DsDynamicListComponent, @@ -277,6 +298,8 @@ const COMPONENTS = [ ClaimedTaskActionsApproveComponent, ClaimedTaskActionsRejectComponent, ClaimedTaskActionsReturnToPoolComponent, + ClaimedTaskActionsEditMetadataComponent, + ClaimedTaskActionsLoaderComponent, ItemActionsComponent, PoolTaskActionsComponent, WorkflowitemActionsComponent, @@ -331,11 +354,22 @@ const COMPONENTS = [ AbstractTrackableComponent, ComcolMetadataComponent, ItemTypeBadgeComponent, + BrowseByComponent, + AbstractTrackableComponent, + CustomSwitchComponent, ItemSelectComponent, CollectionSelectComponent, MetadataRepresentationLoaderComponent, SelectableListItemControlComponent, - ExistingMetadataListElementComponent + ExternalSourceEntryImportModalComponent, + ImportableListItemControlComponent, + ExistingMetadataListElementComponent, + LogInShibbolethComponent, + LogInPasswordComponent, + LogInContainerComponent, + ItemVersionsComponent, + PublicationSearchResultListElementComponent, + ItemVersionsNoticeComponent ]; const ENTRY_COMPONENTS = [ @@ -382,6 +416,7 @@ const ENTRY_COMPONENTS = [ PlainTextMetadataListElementComponent, ItemMetadataListElementComponent, MetadataRepresentationListElementComponent, + CustomSwitchComponent, ItemMetadataRepresentationListElementComponent, SearchResultsComponent, CollectionSearchResultGridElementComponent, @@ -397,7 +432,17 @@ const ENTRY_COMPONENTS = [ SearchAuthorityFilterComponent, DsDynamicLookupRelationSearchTabComponent, DsDynamicLookupRelationSelectionTabComponent, - DsDynamicLookupRelationExternalSourceTabComponent + DsDynamicLookupRelationExternalSourceTabComponent, + ExternalSourceEntryImportModalComponent, + LogInPasswordComponent, + LogInShibbolethComponent, + ItemVersionsComponent, + BundleListElementComponent, + ItemVersionsNoticeComponent, + ClaimedTaskActionsApproveComponent, + ClaimedTaskActionsRejectComponent, + ClaimedTaskActionsReturnToPoolComponent, + ClaimedTaskActionsEditMetadataComponent ]; const SHARED_ITEM_PAGE_COMPONENTS = [ @@ -425,13 +470,14 @@ const DIRECTIVES = [ AutoFocusDirective, RoleDirective, MetadataRepresentationDirective, - ListableObjectDirective + ListableObjectDirective, + ClaimedTaskActionsDirective ]; @NgModule({ imports: [ + ...ROOT_MODULES, ...MODULES, - ...ROOT_MODULES ], declarations: [ ...PIPES, @@ -439,8 +485,7 @@ const DIRECTIVES = [ ...DIRECTIVES, ...ENTRY_COMPONENTS, ...SHARED_ITEM_PAGE_COMPONENTS, - PublicationSearchResultListElementComponent, - ExistingMetadataListElementComponent + ], providers: [ ...PROVIDERS diff --git a/src/app/shared/starts-with/date/starts-with-date.component.spec.ts b/src/app/shared/starts-with/date/starts-with-date.component.spec.ts index 88a1099072..533724f5cb 100644 --- a/src/app/shared/starts-with/date/starts-with-date.component.spec.ts +++ b/src/app/shared/starts-with/date/starts-with-date.component.spec.ts @@ -27,7 +27,7 @@ describe('StartsWithDateComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], declarations: [StartsWithDateComponent, EnumKeysPipe], providers: [ { provide: 'startsWithOptions', useValue: options }, diff --git a/src/app/shared/starts-with/text/starts-with-text.component.spec.ts b/src/app/shared/starts-with/text/starts-with-text.component.spec.ts index 590b46f6de..bc9c21aab8 100644 --- a/src/app/shared/starts-with/text/starts-with-text.component.spec.ts +++ b/src/app/shared/starts-with/text/starts-with-text.component.spec.ts @@ -19,7 +19,7 @@ describe('StartsWithTextComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule.forRoot()], + imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], declarations: [StartsWithTextComponent, EnumKeysPipe], providers: [ { provide: 'startsWithOptions', useValue: options } 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 c822fc15d6..61cf9e8b33 100644 --- a/src/app/shared/testing/eperson-mock.ts +++ b/src/app/shared/testing/eperson-mock.ts @@ -1,15 +1,21 @@ 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: [], + groups: [GroupMock], netid: 'test@test.com', lastActive: '2018-05-14T12:25:42.411+0000', canLogIn: true, email: 'test@test.com', requireCertificate: false, selfRegistered: false, - self: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/epersons/testid', + _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', type: 'eperson', @@ -40,3 +46,49 @@ export const EPersonMock: EPerson = Object.assign(new EPerson(),{ ] } }); + +export const EPersonMock2: EPerson = Object.assign(new EPerson(), { + handle: null, + groups: [], + 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..00068a5eea --- /dev/null +++ b/src/app/shared/testing/group-mock.ts @@ -0,0 +1,38 @@ +import { Group } from '../../core/eperson/models/group.model'; +import { EPersonMock } from './eperson-mock'; + +export const GroupMock2: Group = Object.assign(new Group(), { + handle: null, + subgroups: [], + epersons: [], + permanent: true, + selfRegistered: false, + _links: { + self: { + href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2', + }, + subgroups: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/subgroups' }, + epersons: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid2/epersons' } + }, + id: 'testgroupid2', + uuid: 'testgroupid2', + type: 'group', +}); + +export const GroupMock: Group = Object.assign(new Group(), { + handle: null, + subgroups: [GroupMock2], + epersons: [EPersonMock], + selfRegistered: false, + permanent: false, + _links: { + self: { + href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid', + }, + subgroups: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid/subgroups' }, + epersons: { href: 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups/testgroupid/epersons' } + }, + id: 'testgroupid', + uuid: 'testgroupid', + type: 'group', +}); diff --git a/src/app/shared/testing/test-module.ts b/src/app/shared/testing/test-module.ts index 8f59d76c87..f25fda8d72 100644 --- a/src/app/shared/testing/test-module.ts +++ b/src/app/shared/testing/test-module.ts @@ -1,10 +1,10 @@ -import {CUSTOM_ELEMENTS_SCHEMA, NgModule} from '@angular/core'; -import { QueryParamsDirectiveStub } from './query-params-directive-stub'; +import { CommonModule } from '@angular/common'; +import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { MySimpleItemActionComponent } from '../../+item-page/edit-item-page/simple-item-action/abstract-simple-item-action.component.spec'; -import {CommonModule} from '@angular/common'; -import {SharedModule} from '../shared.module'; -import { RouterLinkDirectiveStub } from './router-link-directive-stub'; +import { SharedModule } from '../shared.module'; import { NgComponentOutletDirectiveStub } from './ng-component-outlet-directive-stub'; +import { QueryParamsDirectiveStub } from './query-params-directive-stub'; +import { RouterLinkDirectiveStub } from './router-link-directive-stub'; /** * This module isn't used. It serves to prevent the AoT compiler @@ -26,4 +26,5 @@ import { NgComponentOutletDirectiveStub } from './ng-component-outlet-directive- CUSTOM_ELEMENTS_SCHEMA ] }) -export class TestModule {} +export class TestModule { +} diff --git a/src/app/shared/trackable/abstract-trackable.component.ts b/src/app/shared/trackable/abstract-trackable.component.ts index cd1b425f10..e1a99d90b9 100644 --- a/src/app/shared/trackable/abstract-trackable.component.ts +++ b/src/app/shared/trackable/abstract-trackable.component.ts @@ -63,7 +63,7 @@ export class AbstractTrackableComponent { * Get translated notification title * @param key */ - private getNotificationTitle(key: string) { + getNotificationTitle(key: string) { return this.translateService.instant(this.notificationsPrefix + key + '.title'); } @@ -71,7 +71,7 @@ export class AbstractTrackableComponent { * Get translated notification content * @param key */ - private getNotificationContent(key: string) { + getNotificationContent(key: string) { return this.translateService.instant(this.notificationsPrefix + key + '.content'); } 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/uploader/uploader-properties.model.ts b/src/app/shared/uploader/uploader-properties.model.ts new file mode 100644 index 0000000000..bc0376b809 --- /dev/null +++ b/src/app/shared/uploader/uploader-properties.model.ts @@ -0,0 +1,21 @@ +import { MetadataMap } from '../../core/shared/metadata.models'; + +/** + * Properties to send to the REST API for uploading a bitstream + */ +export class UploaderProperties { + /** + * A custom name for the bitstream + */ + name: string; + + /** + * Metadata for the bitstream (e.g. dc.description) + */ + metadata: MetadataMap; + + /** + * The name of the bundle to upload the bitstream to + */ + bundleName: string; +} diff --git a/src/app/shared/uploader/uploader.component.ts b/src/app/shared/uploader/uploader.component.ts index 935d196d08..72a38d1eb1 100644 --- a/src/app/shared/uploader/uploader.component.ts +++ b/src/app/shared/uploader/uploader.component.ts @@ -15,8 +15,9 @@ import { uniqueId } from 'lodash'; import { ScrollToConfigOptions, ScrollToService } from '@nicky-lenaers/ngx-scroll-to'; import { UploaderOptions } from './uploader-options.model'; -import { isNotEmpty, isUndefined } from '../empty.util'; +import { hasValue, isNotEmpty, isUndefined } from '../empty.util'; import { UploaderService } from './uploader.service'; +import { UploaderProperties } from './uploader-properties.model'; @Component({ selector: 'ds-uploader', @@ -53,6 +54,11 @@ export class UploaderComponent { */ @Input() uploadFilesOptions: UploaderOptions; + /** + * Extra properties to be passed with the form-data of the upload + */ + @Input() uploadProperties: UploaderProperties; + /** * The function to call when upload is completed */ @@ -131,6 +137,11 @@ export class UploaderComponent { }; this.scrollToService.scrollTo(config); }; + if (hasValue(this.uploadProperties)) { + this.uploader.onBuildItemForm = (item, form) => { + form.append('properties', JSON.stringify(this.uploadProperties)) + }; + } this.uploader.onCompleteItem = (item: any, response: any, status: any, headers: any) => { if (isNotEmpty(response)) { const responsePath = JSON.parse(response); diff --git a/src/app/shared/utils/follow-link-config.model.ts b/src/app/shared/utils/follow-link-config.model.ts new file mode 100644 index 0000000000..87942d8467 --- /dev/null +++ b/src/app/shared/utils/follow-link-config.model.ts @@ -0,0 +1,59 @@ +import { FindListOptions } from '../../core/data/request.models'; +import { HALResource } from '../../core/shared/hal-resource.model'; + +/** + * A class to configure the retrieval of a {@link HALLink} + */ +export class FollowLinkConfig { + /** + * The name of the link to fetch. + * Can only be a {@link HALLink} of the object you're working with + */ + name: keyof R['_links']; + + /** + * {@link FindListOptions} for the query, + * allows you to resolve the link using a certain page, or sorted + * in a certain way + */ + findListOptions?: FindListOptions; + + /** + * A list of {@link FollowLinkConfig}s to + * use on the retrieved object. + */ + linksToFollow?: Array>; + + /** + * Forward to rest which links we're following, so these can already be embedded + */ + shouldEmbed? = true; +} + +/** + * A factory function for {@link FollowLinkConfig}s, + * in order to create them in a less verbose way. + * + * @param linkName: the name of the link to fetch. + * Can only be a {@link HALLink} of the object you're working with + * @param findListOptions: {@link FindListOptions} for the query, + * allows you to resolve the link using a certain page, or sorted + * 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/shared/utils/object-values-pipe.ts b/src/app/shared/utils/object-values-pipe.ts index bb511b4e5c..8c9d863372 100644 --- a/src/app/shared/utils/object-values-pipe.ts +++ b/src/app/shared/utils/object-values-pipe.ts @@ -1,6 +1,10 @@ import { PipeTransform, Pipe } from '@angular/core'; +import { isNotEmpty } from '../empty.util'; -@Pipe({name: 'dsObjectValues'}) +@Pipe({ + name: 'dsObjectValues', + pure: true +}) /** * Pipe for parsing all values of an object to an array of values */ @@ -12,7 +16,9 @@ export class ObjectValuesPipe implements PipeTransform { */ transform(value, args: string[]): any { const values = []; - Object.values(value).forEach((v) => values.push(v)); + if (isNotEmpty(value)) { + Object.values(value).forEach((v) => values.push(v)); + } return values; } } diff --git a/src/app/submission/edit/submission-edit.component.spec.ts b/src/app/submission/edit/submission-edit.component.spec.ts index 115016d2fe..3b8695b023 100644 --- a/src/app/submission/edit/submission-edit.component.spec.ts +++ b/src/app/submission/edit/submission-edit.component.spec.ts @@ -74,7 +74,7 @@ describe('SubmissionEditComponent Component', () => { expect(comp.submissionId).toBe(submissionId); expect(comp.collectionId).toBe(submissionObject.collection.id); - expect(comp.selfUrl).toBe(submissionObject.self); + expect(comp.selfUrl).toBe(submissionObject._links.self.href); expect(comp.sections).toBe(submissionObject.sections); expect(comp.submissionDefinition).toBe(submissionObject.submissionDefinition); diff --git a/src/app/submission/edit/submission-edit.component.ts b/src/app/submission/edit/submission-edit.component.ts index 60c8b9a7a3..908f473136 100644 --- a/src/app/submission/edit/submission-edit.component.ts +++ b/src/app/submission/edit/submission-edit.component.ts @@ -94,7 +94,7 @@ export class SubmissionEditComponent implements OnDestroy, OnInit { } else { this.submissionId = submissionObjectRD.payload.id.toString(); this.collectionId = (submissionObjectRD.payload.collection as Collection).id; - this.selfUrl = submissionObjectRD.payload.self; + this.selfUrl = submissionObjectRD.payload._links.self.href; this.sections = submissionObjectRD.payload.sections; this.submissionDefinition = (submissionObjectRD.payload.submissionDefinition as SubmissionDefinitionsModel); this.changeDetectorRef.detectChanges(); diff --git a/src/app/submission/form/collection/submission-form-collection.component.spec.ts b/src/app/submission/form/collection/submission-form-collection.component.spec.ts index 8539560d26..ff91d86736 100644 --- a/src/app/submission/form/collection/submission-form-collection.component.spec.ts +++ b/src/app/submission/form/collection/submission-form-collection.component.spec.ts @@ -222,7 +222,7 @@ describe('SubmissionFormCollectionComponent Component', () => { imports: [ FormsModule, ReactiveFormsModule, - NgbModule.forRoot(), + NgbModule, TranslateModule.forRoot() ], declarations: [ diff --git a/src/app/submission/form/footer/submission-form-footer.component.spec.ts b/src/app/submission/form/footer/submission-form-footer.component.spec.ts index 5fbfd84cb8..d786faeee8 100644 --- a/src/app/submission/form/footer/submission-form-footer.component.spec.ts +++ b/src/app/submission/form/footer/submission-form-footer.component.spec.ts @@ -34,7 +34,7 @@ describe('SubmissionFormFooterComponent Component', () => { beforeEach(async(() => { TestBed.configureTestingModule({ imports: [ - NgbModule.forRoot(), + NgbModule, TranslateModule.forRoot() ], declarations: [ @@ -188,7 +188,7 @@ describe('SubmissionFormFooterComponent Component', () => { expect(submissionServiceStub.dispatchDeposit).toHaveBeenCalledWith(submissionId); }); - it('should call dispatchDiscard on discard confirmation', fakeAsync(() => { + it('should call dispatchDiscard on discard confirmation', () => { comp.showDepositAndDiscard = observableOf(true); fixture.detectChanges(); const modalBtn = fixture.debugElement.query(By.css('.btn-danger')); @@ -204,7 +204,7 @@ describe('SubmissionFormFooterComponent Component', () => { fixture.whenStable().then(() => { expect(submissionServiceStub.dispatchDiscard).toHaveBeenCalledWith(submissionId); }); - })); + }); it('should have deposit button disabled when submission is not valid', () => { comp.showDepositAndDiscard = observableOf(true); diff --git a/src/app/submission/form/section-add/submission-form-section-add.component.spec.ts b/src/app/submission/form/section-add/submission-form-section-add.component.spec.ts index 236bd6de9b..5a2978b17c 100644 --- a/src/app/submission/form/section-add/submission-form-section-add.component.spec.ts +++ b/src/app/submission/form/section-add/submission-form-section-add.component.spec.ts @@ -59,7 +59,7 @@ describe('SubmissionFormSectionAddComponent Component', () => { beforeEach(async(() => { TestBed.configureTestingModule({ imports: [ - NgbModule.forRoot(), + NgbModule, TranslateModule.forRoot() ], declarations: [ diff --git a/src/app/submission/form/submission-form.component.ts b/src/app/submission/form/submission-form.component.ts index 3ea07f9ae7..0b8cfce619 100644 --- a/src/app/submission/form/submission-form.component.ts +++ b/src/app/submission/form/submission-form.component.ts @@ -1,19 +1,19 @@ import { ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, SimpleChanges } from '@angular/core'; -import { of as observableOf, Observable, Subscription } from 'rxjs'; -import { distinctUntilChanged, filter, flatMap, map, switchMap } from 'rxjs/operators'; +import { Observable, of as observableOf, Subscription } from 'rxjs'; +import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators'; +import { AuthService } from '../../core/auth/auth.service'; +import { SubmissionDefinitionsModel } from '../../core/config/models/config-submission-definitions.model'; +import { Collection } from '../../core/shared/collection.model'; +import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; +import { SubmissionObject } from '../../core/submission/models/submission-object.model'; +import { WorkspaceitemSectionsObject } from '../../core/submission/models/workspaceitem-sections.model'; import { hasValue, isNotEmpty } from '../../shared/empty.util'; -import { SubmissionObjectEntry } from '../objects/submission-objects.reducer'; -import { WorkspaceitemSectionsObject } from '../../core/submission/models/workspaceitem-sections.model'; -import { SubmissionDefinitionsModel } from '../../core/config/models/config-submission-definitions.model'; -import { SubmissionService } from '../submission.service'; -import { AuthService } from '../../core/auth/auth.service'; -import { SectionDataObject } from '../sections/models/section-data.model'; import { UploaderOptions } from '../../shared/uploader/uploader-options.model'; -import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; -import { Collection } from '../../core/shared/collection.model'; -import { SubmissionObject } from '../../core/submission/models/submission-object.model'; +import { SubmissionObjectEntry } from '../objects/submission-objects.reducer'; +import { SectionDataObject } from '../sections/models/section-data.model'; +import { SubmissionService } from '../submission.service'; /** * This component represents the submission form. @@ -189,7 +189,7 @@ export class SubmissionFormComponent implements OnChanges, OnDestroy { this.submissionService.resetSubmissionObject( this.collectionId, this.submissionId, - submissionObject.self, + submissionObject._links.self.href, this.submissionDefinition, this.sections); } else { diff --git a/src/app/submission/objects/submission-objects.actions.ts b/src/app/submission/objects/submission-objects.actions.ts index 9bd88f035a..57226fc531 100644 --- a/src/app/submission/objects/submission-objects.actions.ts +++ b/src/app/submission/objects/submission-objects.actions.ts @@ -796,4 +796,5 @@ export type SubmissionObjectAction = DisableSectionAction | SaveSubmissionSectionFormAction | SaveSubmissionSectionFormSuccessAction | SaveSubmissionSectionFormErrorAction - | SetActiveSectionAction; + | SetActiveSectionAction + | DepositSubmissionAction; diff --git a/src/app/submission/objects/submission-objects.effects.spec.ts b/src/app/submission/objects/submission-objects.effects.spec.ts index 8bbdd4e0ee..40c5cc9dd0 100644 --- a/src/app/submission/objects/submission-objects.effects.spec.ts +++ b/src/app/submission/objects/submission-objects.effects.spec.ts @@ -109,8 +109,8 @@ describe('SubmissionObjectEffects test suite', () => { const mappedActions = []; (submissionDefinitionResponse.sections as SubmissionSectionModel[]) .forEach((sectionDefinition: SubmissionSectionModel) => { - const sectionId = sectionDefinition._links.self.substr(sectionDefinition._links.self.lastIndexOf('/') + 1); - const config = sectionDefinition._links.config || ''; + const sectionId = sectionDefinition._links.self.href.substr(sectionDefinition._links.self.href.lastIndexOf('/') + 1); + const config = sectionDefinition._links.config.href || ''; const enabled = (sectionDefinition.mandatory); const sectionData = {}; const sectionErrors = null; diff --git a/src/app/submission/objects/submission-objects.effects.ts b/src/app/submission/objects/submission-objects.effects.ts index ba82fe1e65..a2a3350c6a 100644 --- a/src/app/submission/objects/submission-objects.effects.ts +++ b/src/app/submission/objects/submission-objects.effects.ts @@ -1,10 +1,24 @@ import { Injectable } from '@angular/core'; import { Actions, Effect, ofType } from '@ngrx/effects'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { union } from 'lodash'; import { from as observableFrom, of as observableOf } from 'rxjs'; import { catchError, map, mergeMap, switchMap, tap, withLatestFrom } from 'rxjs/operators'; -import { Store } from '@ngrx/store'; -import { union } from 'lodash'; +import { SubmissionObject } from '../../core/submission/models/submission-object.model'; +import { WorkflowItem } from '../../core/submission/models/workflowitem.model'; +import { WorkspaceitemSectionUploadObject } from '../../core/submission/models/workspaceitem-section-upload.model'; +import { WorkspaceitemSectionsObject } from '../../core/submission/models/workspaceitem-sections.model'; +import { WorkspaceItem } from '../../core/submission/models/workspaceitem.model'; +import { SubmissionJsonPatchOperationsService } from '../../core/submission/submission-json-patch-operations.service'; +import { isEmpty, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { SectionsType } from '../sections/sections-type'; +import { SectionsService } from '../sections/sections.service'; +import { SubmissionState } from '../submission.reducers'; +import { SubmissionService } from '../submission.service'; +import parseSectionErrors from '../utils/parseSectionErrors'; import { CompleteInitSubmissionFormAction, @@ -24,26 +38,12 @@ import { SaveSubmissionFormSuccessAction, SaveSubmissionSectionFormAction, SaveSubmissionSectionFormErrorAction, - SaveSubmissionSectionFormSuccessAction, SubmissionObjectAction, + SaveSubmissionSectionFormSuccessAction, + SubmissionObjectAction, SubmissionObjectActionTypes, UpdateSectionDataAction } from './submission-objects.actions'; -import { SectionsService } from '../sections/sections.service'; -import { isEmpty, isNotEmpty, isNotUndefined } from '../../shared/empty.util'; -import { WorkspaceItem } from '../../core/submission/models/workspaceitem.model'; -import { SubmissionService } from '../submission.service'; -import { WorkflowItem } from '../../core/submission/models/workflowitem.model'; -import { NotificationsService } from '../../shared/notifications/notifications.service'; -import { SubmissionObject } from '../../core/submission/models/submission-object.model'; -import { TranslateService } from '@ngx-translate/core'; -import { SubmissionState } from '../submission.reducers'; import { SubmissionObjectEntry } from './submission-objects.reducer'; -import { SubmissionSectionModel } from '../../core/config/models/config-submission-section.model'; -import parseSectionErrors from '../utils/parseSectionErrors'; -import { WorkspaceitemSectionsObject } from '../../core/submission/models/workspaceitem-sections.model'; -import { WorkspaceitemSectionUploadObject } from '../../core/submission/models/workspaceitem-section-upload.model'; -import { SectionsType } from '../sections/sections-type'; -import { SubmissionJsonPatchOperationsService } from '../../core/submission/submission-json-patch-operations.service'; @Injectable() export class SubmissionObjectEffects { @@ -56,9 +56,10 @@ export class SubmissionObjectEffects { map((action: InitSubmissionFormAction) => { const definition = action.payload.submissionDefinition; const mappedActions = []; - definition.sections.page.forEach((sectionDefinition: SubmissionSectionModel) => { - const sectionId = sectionDefinition._links.self.substr(sectionDefinition._links.self.lastIndexOf('/') + 1); - const config = sectionDefinition._links.config || ''; + definition.sections.page.forEach((sectionDefinition: any) => { + const selfLink = sectionDefinition._links.self.href || sectionDefinition._links.self; + const sectionId = selfLink.substr(selfLink.lastIndexOf('/') + 1); + const config = sectionDefinition._links.config ? (sectionDefinition._links.config.href || sectionDefinition._links.config) : ''; const enabled = (sectionDefinition.mandatory) || (isNotEmpty(action.payload.sections) && action.payload.sections.hasOwnProperty(sectionId)); const sectionData = (isNotUndefined(action.payload.sections) && isNotUndefined(action.payload.sections[sectionId])) ? action.payload.sections[sectionId] : Object.create(null); const sectionErrors = null; diff --git a/src/app/submission/objects/submission-objects.reducer.spec.ts b/src/app/submission/objects/submission-objects.reducer.spec.ts index a5e0be451b..7fdccf3ebb 100644 --- a/src/app/submission/objects/submission-objects.reducer.spec.ts +++ b/src/app/submission/objects/submission-objects.reducer.spec.ts @@ -22,14 +22,13 @@ import { SaveAndDepositSubmissionAction, SaveForLaterSubmissionFormAction, SaveForLaterSubmissionFormErrorAction, - SaveForLaterSubmissionFormSuccessAction, SaveSubmissionFormAction, SaveSubmissionFormErrorAction, SaveSubmissionFormSuccessAction, SaveSubmissionSectionFormAction, SaveSubmissionSectionFormErrorAction, SaveSubmissionSectionFormSuccessAction, - SectionStatusChangeAction, + SectionStatusChangeAction, SubmissionObjectAction, UpdateSectionDataAction } from './submission-objects.actions'; import { SectionsType } from '../sections/sections-type'; @@ -117,7 +116,7 @@ describe('submissionReducer test suite', () => { }); it('should set to true savePendig flag on save', () => { - let action = new SaveSubmissionFormAction(submissionId); + let action: SubmissionObjectAction = new SaveSubmissionFormAction(submissionId); let newState = submissionObjectReducer(initState, action); expect(newState[826].savePending).toBeTruthy(); @@ -273,7 +272,7 @@ describe('submissionReducer test suite', () => { it('should enable submission section properly', () => { - let action = new EnableSectionAction(submissionId, 'traditionalpagetwo'); + let action: SubmissionObjectAction = new EnableSectionAction(submissionId, 'traditionalpagetwo'); let newState = submissionObjectReducer(initState, action); action = new DisableSectionAction(submissionId, 'traditionalpagetwo'); diff --git a/src/app/submission/objects/submission-objects.reducer.ts b/src/app/submission/objects/submission-objects.reducer.ts index 8c111dde67..e0aeefd7b6 100644 --- a/src/app/submission/objects/submission-objects.reducer.ts +++ b/src/app/submission/objects/submission-objects.reducer.ts @@ -548,7 +548,7 @@ function startDeposit(state: SubmissionObjectState, action: DepositSubmissionAct * @return SubmissionObjectState * the new state, with the deposit flag changed. */ -function endDeposit(state: SubmissionObjectState, action: DepositSubmissionSuccessAction | DepositSubmissionErrorAction): SubmissionObjectState { +function endDeposit(state: SubmissionObjectState, action: DepositSubmissionSuccessAction | DepositSubmissionErrorAction | DepositSubmissionAction): SubmissionObjectState { if (hasValue(state[ action.payload.submissionId ])) { return Object.assign({}, state, { [ action.payload.submissionId ]: Object.assign({}, state[ action.payload.submissionId ], { diff --git a/src/app/submission/sections/container/section-container.component.scss b/src/app/submission/sections/container/section-container.component.scss index 3917280f8c..0255b71dac 100644 --- a/src/app/submission/sections/container/section-container.component.scss +++ b/src/app/submission/sections/container/section-container.component.scss @@ -1,4 +1,4 @@ -:host /deep/ .card { +:host ::ng-deep .card { margin-bottom: $submission-sections-margin-bottom; overflow: unset; } @@ -9,13 +9,13 @@ } // TODO to remove the following when upgrading @ng-bootstrap -:host /deep/ .card:first-of-type { +: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 /deep/ .card-header button { +:host ::ng-deep .card-header button { box-shadow: none !important; width: 100%; } diff --git a/src/app/submission/sections/container/section-container.component.spec.ts b/src/app/submission/sections/container/section-container.component.spec.ts index 778aa4ab84..38b8572d0c 100644 --- a/src/app/submission/sections/container/section-container.component.spec.ts +++ b/src/app/submission/sections/container/section-container.component.spec.ts @@ -67,7 +67,7 @@ describe('SubmissionSectionContainerComponent test suite', () => { TestBed.configureTestingModule({ imports: [ - NgbModule.forRoot(), + NgbModule, TranslateModule.forRoot() ], declarations: [ diff --git a/src/app/submission/sections/container/section-container.component.ts b/src/app/submission/sections/container/section-container.component.ts index f040288667..a48bf8cb92 100644 --- a/src/app/submission/sections/container/section-container.component.ts +++ b/src/app/submission/sections/container/section-container.component.ts @@ -48,7 +48,7 @@ export class SubmissionSectionContainerComponent implements OnInit { /** * The SectionsDirective reference */ - @ViewChild('sectionRef') sectionRef: SectionsDirective; + @ViewChild('sectionRef', {static: false}) sectionRef: SectionsDirective; /** * Initialize instance variables diff --git a/src/app/submission/sections/form/section-form.component.spec.ts b/src/app/submission/sections/form/section-form.component.spec.ts index be13c14941..10f6655ce1 100644 --- a/src/app/submission/sections/form/section-form.component.spec.ts +++ b/src/app/submission/sections/form/section-form.component.spec.ts @@ -43,9 +43,6 @@ import { SubmissionSectionError } from '../../objects/submission-objects.reducer import { DynamicFormControlEvent, DynamicFormControlEventType } from '@ng-dynamic-forms/core'; import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; import { FormRowModel } from '../../../core/config/models/config-submission-form.model'; -import { WorkspaceitemDataService } from '../../../core/submission/workspaceitem-data.service'; -import { RemoteData } from '../../../core/data/remote-data'; -import { WorkspaceItem } from '../../../core/submission/models/workspaceitem.model'; function getMockSubmissionFormsConfigService(): SubmissionFormsConfigService { return jasmine.createSpyObj('FormOperationsService', { @@ -108,10 +105,11 @@ const testFormConfiguration = { ] } as FormRowModel, ], - self: 'testFormConfiguration.url', type: 'submissionform', _links: { - self: 'testFormConfiguration.url' + self: { + href: 'testFormConfiguration.url' + } } } as any; @@ -182,7 +180,6 @@ describe('SubmissionSectionformComponent test suite', () => { { provide: 'collectionIdProvider', useValue: collectionId }, { provide: 'sectionDataProvider', useValue: sectionObject }, { provide: 'submissionIdProvider', useValue: submissionId }, - { provide: WorkspaceitemDataService, useValue: {findById: () => observableOf(new RemoteData(false, false, true, null, new WorkspaceItem()))}}, ChangeDetectorRef, SubmissionSectionformComponent ], diff --git a/src/app/submission/sections/form/section-form.component.ts b/src/app/submission/sections/form/section-form.component.ts index 49dbaea807..76e7339f17 100644 --- a/src/app/submission/sections/form/section-form.component.ts +++ b/src/app/submission/sections/form/section-form.component.ts @@ -15,10 +15,7 @@ import { hasValue, isNotEmpty, isUndefined } from '../../../shared/empty.util'; import { ConfigData } from '../../../core/config/config-data'; import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; import { SubmissionFormsModel } from '../../../core/config/models/config-submission-forms.model'; -import { - SubmissionSectionError, - SubmissionSectionObject -} from '../../objects/submission-objects.reducer'; +import { SubmissionSectionError, SubmissionSectionObject } from '../../objects/submission-objects.reducer'; import { FormFieldPreviousValueObject } from '../../../shared/form/builder/models/form-field-previous-value-object'; import { GLOBAL_CONFIG } from '../../../../config'; import { GlobalConfig } from '../../../../config/global-config.interface'; @@ -31,11 +28,7 @@ import { NotificationsService } from '../../../shared/notifications/notification import { SectionsService } from '../sections.service'; import { difference } from '../../../shared/object.util'; import { WorkspaceitemSectionFormObject } from '../../../core/submission/models/workspaceitem-section-form.model'; -import { WorkspaceItem } from '../../../core/submission/models/workspaceitem.model'; import { WorkspaceitemDataService } from '../../../core/submission/workspaceitem-data.service'; -import { combineLatest as combineLatestObservable } from 'rxjs'; -import { getSucceededRemoteData } from '../../../core/shared/operators'; -import { RemoteData } from '../../../core/data/remote-data'; /** * This component represents a section that contains a Form. @@ -108,11 +101,10 @@ export class SubmissionSectionformComponent extends SectionModelComponent { */ protected subs: Subscription[] = []; - protected workspaceItem: WorkspaceItem; /** * The FormComponent reference */ - @ViewChild('formRef') private formRef: FormComponent; + @ViewChild('formRef', {static: false}) private formRef: FormComponent; /** * Initialize instance variables @@ -140,7 +132,6 @@ export class SubmissionSectionformComponent extends SectionModelComponent { protected sectionService: SectionsService, protected submissionService: SubmissionService, protected translate: TranslateService, - protected workspaceItemDataService: WorkspaceitemDataService, @Inject(GLOBAL_CONFIG) protected EnvConfig: GlobalConfig, @Inject('collectionIdProvider') public injectedCollectionId: string, @Inject('sectionDataProvider') public injectedSectionData: SectionDataObject, @@ -157,16 +148,11 @@ export class SubmissionSectionformComponent extends SectionModelComponent { this.formConfigService.getConfigByHref(this.sectionData.config).pipe( map((configData: ConfigData) => configData.payload), tap((config: SubmissionFormsModel) => this.formConfig = config), - flatMap(() => - combineLatestObservable( - this.sectionService.getSectionData(this.submissionId, this.sectionData.id), - this.workspaceItemDataService.findById(this.submissionId).pipe(getSucceededRemoteData(), map((wsiRD: RemoteData) => wsiRD.payload)) - )), + flatMap(() => this.sectionService.getSectionData(this.submissionId, this.sectionData.id)), take(1)) - .subscribe(([sectionData, workspaceItem]: [WorkspaceitemSectionFormObject, WorkspaceItem]) => { + .subscribe((sectionData: WorkspaceitemSectionFormObject) => { if (isUndefined(this.formModel)) { this.sectionData.errors = []; - this.workspaceItem = workspaceItem; // Is the first loading so init form this.initForm(sectionData); this.sectionData.data = sectionData; diff --git a/src/app/submission/sections/license/section-license.component.ts b/src/app/submission/sections/license/section-license.component.ts index 940460c83d..e6915112e5 100644 --- a/src/app/submission/sections/license/section-license.component.ts +++ b/src/app/submission/sections/license/section-license.component.ts @@ -1,7 +1,4 @@ import { ChangeDetectorRef, Component, Inject, ViewChild } from '@angular/core'; - -import { Observable, Subscription } from 'rxjs'; -import { distinctUntilChanged, filter, find, flatMap, map, startWith, take } from 'rxjs/operators'; import { DynamicCheckboxModel, DynamicFormControlEvent, @@ -9,25 +6,29 @@ import { DynamicFormLayout } from '@ng-dynamic-forms/core'; -import { SectionModelComponent } from '../models/section.model'; -import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { Observable, Subscription } from 'rxjs'; +import { distinctUntilChanged, filter, find, flatMap, map, startWith, take } from 'rxjs/operators'; import { CollectionDataService } from '../../../core/data/collection-data.service'; -import { hasValue, isNotEmpty, isNotNull, isNotUndefined } from '../../../shared/empty.util'; -import { License } from '../../../core/shared/license.model'; import { RemoteData } from '../../../core/data/remote-data'; -import { Collection } from '../../../core/shared/collection.model'; -import { SECTION_LICENSE_FORM_LAYOUT, SECTION_LICENSE_FORM_MODEL } from './section-license.model'; -import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; -import { FormService } from '../../../shared/form/form.service'; import { JsonPatchOperationPathCombiner } from '../../../core/json-patch/builder/json-patch-operation-path-combiner'; -import { SectionsType } from '../sections-type'; -import { renderSectionFor } from '../sections-decorator'; -import { SectionDataObject } from '../models/section-data.model'; +import { JsonPatchOperationsBuilder } from '../../../core/json-patch/builder/json-patch-operations-builder'; +import { Collection } from '../../../core/shared/collection.model'; +import { License } from '../../../core/shared/license.model'; import { WorkspaceitemSectionLicenseObject } from '../../../core/submission/models/workspaceitem-section-license.model'; -import { SubmissionService } from '../../submission.service'; -import { SectionsService } from '../sections.service'; -import { SectionFormOperationsService } from '../form/section-form-operations.service'; +import { hasValue, isNotEmpty, isNotNull, isNotUndefined } from '../../../shared/empty.util'; +import { FormBuilderService } from '../../../shared/form/builder/form-builder.service'; import { FormComponent } from '../../../shared/form/form.component'; +import { FormService } from '../../../shared/form/form.service'; +import { followLink } from '../../../shared/utils/follow-link-config.model'; +import { SubmissionService } from '../../submission.service'; +import { SectionFormOperationsService } from '../form/section-form-operations.service'; +import { SectionDataObject } from '../models/section-data.model'; + +import { SectionModelComponent } from '../models/section.model'; +import { renderSectionFor } from '../sections-decorator'; +import { SectionsType } from '../sections-type'; +import { SectionsService } from '../sections.service'; +import { SECTION_LICENSE_FORM_LAYOUT, SECTION_LICENSE_FORM_MODEL } from './section-license.model'; /** * This component represents a section that contains the submission license form. @@ -85,7 +86,7 @@ export class SubmissionSectionLicenseComponent extends SectionModelComponent { /** * The FormComponent reference */ - @ViewChild('formRef') private formRef: FormComponent; + @ViewChild('formRef', {static: false}) private formRef: FormComponent; /** * Initialize instance variables @@ -132,9 +133,9 @@ export class SubmissionSectionLicenseComponent extends SectionModelComponent { (model as DynamicCheckboxModel).valueUpdates.next(false); } - this.licenseText$ = this.collectionDataService.findById(this.collectionId).pipe( + this.licenseText$ = this.collectionDataService.findById(this.collectionId, followLink('license')).pipe( filter((collectionData: RemoteData) => isNotUndefined((collectionData.payload))), - flatMap((collectionData: RemoteData) => collectionData.payload.license), + flatMap((collectionData: RemoteData) => (collectionData.payload as any).license), find((licenseData: RemoteData) => isNotUndefined((licenseData.payload))), map((licenseData: RemoteData) => licenseData.payload.text), startWith('')); diff --git a/src/app/submission/sections/upload/accessConditions/submission-section-upload-access-conditions.component.ts b/src/app/submission/sections/upload/accessConditions/submission-section-upload-access-conditions.component.ts index 43b0a7da3f..04852cc014 100644 --- a/src/app/submission/sections/upload/accessConditions/submission-section-upload-access-conditions.component.ts +++ b/src/app/submission/sections/upload/accessConditions/submission-section-upload-access-conditions.component.ts @@ -2,7 +2,7 @@ import { Component, Input, OnInit } from '@angular/core'; import { find } from 'rxjs/operators'; -import { GroupEpersonService } from '../../../../core/eperson/group-eperson.service'; +import { GroupDataService } from '../../../../core/eperson/group-data.service'; import { ResourcePolicy } from '../../../../core/shared/resource-policy.model'; import { isEmpty } from '../../../../shared/empty.util'; import { Group } from '../../../../core/eperson/models/group.model'; @@ -32,9 +32,9 @@ export class SubmissionSectionUploadAccessConditionsComponent implements OnInit /** * Initialize instance variables * - * @param {GroupEpersonService} groupService + * @param {GroupDataService} groupService */ - constructor(private groupService: GroupEpersonService) {} + constructor(private groupService: GroupDataService) {} /** * Retrieve access conditions list 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 8cf0d22d20..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'; @@ -125,7 +127,7 @@ export class SubmissionSectionUploadFileEditComponent implements OnChanges { /** * The FormComponent reference */ - @ViewChild('formRef') public formRef: FormComponent; + @ViewChild('formRef', {static: false}) public formRef: FormComponent; /** * Initialize instance variables @@ -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); @@ -334,7 +336,7 @@ export class SubmissionSectionUploadFileEditComponent implements OnChanges { }); // Due to a bug can't dynamically change the select options, so replace the model with a new one - const confGroup = { relation: groupModel.relation }; + const confGroup = { relation: groupModel.relations }; const groupsConfig = Object.assign({}, BITSTREAM_FORM_ACCESS_CONDITION_GROUPS_CONFIG, confGroup); groupsConfig.options = groupOptions; (model.parent as DynamicFormGroupModel).group.pop(); 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 ec72adf786..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' } }; @@ -50,10 +53,10 @@ export const BITSTREAM_FORM_ACCESS_CONDITION_START_DATE_CONFIG: DynamicDatePicke placeholder: 'submission.sections.upload.form.from-placeholder', inline: false, toggleIcon: 'far fa-calendar-alt', - relation: [ + relations: [ { - action: 'ENABLE', - connective: 'OR', + match: MATCH_ENABLED, + operator: OR_OPERATOR, when: [] } ], @@ -81,10 +84,10 @@ export const BITSTREAM_FORM_ACCESS_CONDITION_END_DATE_CONFIG: DynamicDatePickerM placeholder: 'submission.sections.upload.form.until-placeholder', inline: false, toggleIcon: 'far fa-calendar-alt', - relation: [ + relations: [ { - action: 'ENABLE', - connective: 'OR', + match: MATCH_ENABLED, + operator: OR_OPERATOR, when: [] } ], @@ -110,10 +113,10 @@ export const BITSTREAM_FORM_ACCESS_CONDITION_GROUPS_CONFIG: DynamicSelectModelCo id: 'groupUUID', label: 'submission.sections.upload.form.group-label', options: [], - relation: [ + relations: [ { - action: 'ENABLE', - connective: 'OR', + match: MATCH_ENABLED, + operator: OR_OPERATOR, when: [] } ], diff --git a/src/app/submission/sections/upload/file/section-upload-file.component.spec.ts b/src/app/submission/sections/upload/file/section-upload-file.component.spec.ts index 54b51e7afc..4e0badb76d 100644 --- a/src/app/submission/sections/upload/file/section-upload-file.component.spec.ts +++ b/src/app/submission/sections/upload/file/section-upload-file.component.spec.ts @@ -86,7 +86,7 @@ describe('SubmissionSectionUploadFileComponent test suite', () => { imports: [ BrowserModule, CommonModule, - NgbModule.forRoot(), + NgbModule, TranslateModule.forRoot() ], declarations: [ @@ -190,7 +190,7 @@ describe('SubmissionSectionUploadFileComponent test suite', () => { expect(comp.fileData).toEqual(fileData); }); - it('should call deleteFile on delete confirmation', fakeAsync(() => { + it('should call deleteFile on delete confirmation', () => { spyOn(compAsAny, 'deleteFile'); comp.fileData = fileData; @@ -209,7 +209,7 @@ describe('SubmissionSectionUploadFileComponent test suite', () => { fixture.whenStable().then(() => { expect(compAsAny.deleteFile).toHaveBeenCalled(); }); - })); + }); it('should delete file properly', () => { compAsAny.pathCombiner = pathCombiner; diff --git a/src/app/submission/sections/upload/file/section-upload-file.component.ts b/src/app/submission/sections/upload/file/section-upload-file.component.ts index 9923c358e7..c0ad31165b 100644 --- a/src/app/submission/sections/upload/file/section-upload-file.component.ts +++ b/src/app/submission/sections/upload/file/section-upload-file.component.ts @@ -141,7 +141,7 @@ export class SubmissionSectionUploadFileComponent implements OnChanges, OnInit { * The [[SubmissionSectionUploadFileEditComponent]] reference * @type {SubmissionSectionUploadFileEditComponent} */ - @ViewChild(SubmissionSectionUploadFileEditComponent) fileEditComp: SubmissionSectionUploadFileEditComponent; + @ViewChild(SubmissionSectionUploadFileEditComponent, {static: false}) fileEditComp: SubmissionSectionUploadFileEditComponent; /** * Initialize instance variables diff --git a/src/app/submission/sections/upload/section-upload.component.spec.ts b/src/app/submission/sections/upload/section-upload.component.spec.ts index fd9f88d939..af865b81eb 100644 --- a/src/app/submission/sections/upload/section-upload.component.spec.ts +++ b/src/app/submission/sections/upload/section-upload.component.spec.ts @@ -5,6 +5,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { of as observableOf } from 'rxjs'; import { createSuccessfulRemoteDataObject$, createTestComponent } from '../../../shared/testing/utils'; +import { SubmissionObjectState } from '../../objects/submission-objects.reducer'; import { SubmissionService } from '../../submission.service'; import { SubmissionServiceStub } from '../../../shared/testing/submission-service-stub'; import { SectionsService } from '../sections.service'; @@ -18,7 +19,7 @@ import { mockSubmissionId, mockSubmissionState, mockUploadConfigResponse, - mockUploadFiles + mockUploadConfigResponseNotRequired, mockUploadFiles, } from '../../../shared/mocks/mock-submission'; import { BrowserModule } from '@angular/platform-browser'; import { CommonModule } from '@angular/common'; @@ -26,12 +27,11 @@ import { SubmissionUploadsConfigService } from '../../../core/config/submission- import { SectionUploadService } from './section-upload.service'; import { SubmissionSectionUploadComponent } from './section-upload.component'; import { CollectionDataService } from '../../../core/data/collection-data.service'; -import { GroupEpersonService } from '../../../core/eperson/group-eperson.service'; +import { GroupDataService } from '../../../core/eperson/group-data.service'; import { cold, hot } from 'jasmine-marbles'; import { Collection } from '../../../core/shared/collection.model'; import { ResourcePolicy } from '../../../core/shared/resource-policy.model'; import { ResourcePolicyService } from '../../../core/data/resource-policy.service'; -import { RemoteData } from '../../../core/data/remote-data'; import { ConfigData } from '../../../core/config/config-data'; import { PageInfo } from '../../../core/shared/page-info.model'; import { Group } from '../../../core/eperson/models/group.model'; @@ -52,8 +52,8 @@ function getMockCollectionDataService(): CollectionDataService { }); } -function getMockGroupEpersonService(): GroupEpersonService { - return jasmine.createSpyObj('GroupEpersonService', { +function getMockGroupEpersonService(): GroupDataService { + return jasmine.createSpyObj('GroupDataService', { findById: jasmine.createSpy('findById'), }); @@ -65,17 +65,7 @@ function getMockResourcePolicyService(): ResourcePolicyService { }); } -const sectionObject: SectionDataObject = { - config: 'https://dspace7.4science.it/or2018/api/config/submissionforms/upload', - mandatory: true, - data: { - files: [] - }, - errors: [], - header: 'submit.progressbar.describe.upload', - id: 'upload', - sectionType: SectionsType.Upload -}; +let sectionObject: SectionDataObject; describe('SubmissionSectionUploadComponent test suite', () => { @@ -90,30 +80,48 @@ describe('SubmissionSectionUploadComponent test suite', () => { let uploadsConfigService: any; let bitstreamService: any; - const submissionId = mockSubmissionId; - const collectionId = mockSubmissionCollectionId; - const submissionState = Object.assign({}, mockSubmissionState[mockSubmissionId]); - const mockCollection = Object.assign(new Collection(), { - name: 'Community 1-Collection 1', - id: collectionId, - metadata: [ - { - key: 'dc.title', - language: 'en_US', - value: 'Community 1-Collection 1' - }], - _links: { - defaultAccessConditions: collectionId + '/defaultAccessConditions' - } - }); - const mockDefaultAccessCondition = Object.assign(new ResourcePolicy(), { - name: null, - groupUUID: '11cc35e5-a11d-4b64-b5b9-0052a5d15509', - id: 20, - uuid: 'resource-policy-20' - }); + let submissionId: string; + let collectionId: string; + let submissionState: SubmissionObjectState; + let mockCollection: Collection; + let mockDefaultAccessCondition: ResourcePolicy; beforeEach(async(() => { + sectionObject = { + config: 'https://dspace7.4science.it/or2018/api/config/submissionforms/upload', + mandatory: true, + data: { + files: [] + }, + errors: [], + header: 'submit.progressbar.describe.upload', + id: 'upload', + sectionType: SectionsType.Upload + }; + submissionId = mockSubmissionId; + collectionId = mockSubmissionCollectionId; + submissionState = Object.assign({}, mockSubmissionState[mockSubmissionId]) as any; + mockCollection = Object.assign(new Collection(), { + name: 'Community 1-Collection 1', + id: collectionId, + metadata: [ + { + key: 'dc.title', + language: 'en_US', + value: 'Community 1-Collection 1' + }], + _links: { + defaultAccessConditions: collectionId + '/defaultAccessConditions' + } + }); + + mockDefaultAccessCondition = Object.assign(new ResourcePolicy(), { + name: null, + groupUUID: '11cc35e5-a11d-4b64-b5b9-0052a5d15509', + id: 20, + uuid: 'resource-policy-20' + }); + TestBed.configureTestingModule({ imports: [ BrowserModule, @@ -126,7 +134,7 @@ describe('SubmissionSectionUploadComponent test suite', () => { ], providers: [ { provide: CollectionDataService, useValue: getMockCollectionDataService() }, - { provide: GroupEpersonService, useValue: getMockGroupEpersonService() }, + { provide: GroupDataService, useValue: getMockGroupEpersonService() }, { provide: ResourcePolicyService, useValue: getMockResourcePolicyService() }, { provide: SubmissionUploadsConfigService, useValue: getMockSubmissionUploadsConfigService() }, { provide: SectionsService, useClass: SectionsServiceStub }, @@ -173,7 +181,7 @@ describe('SubmissionSectionUploadComponent test suite', () => { submissionServiceStub = TestBed.get(SubmissionService); sectionsServiceStub = TestBed.get(SectionsService); collectionDataService = TestBed.get(CollectionDataService); - groupService = TestBed.get(GroupEpersonService); + groupService = TestBed.get(GroupDataService); resourcePolicyService = TestBed.get(ResourcePolicyService); uploadsConfigService = TestBed.get(SubmissionUploadsConfigService); bitstreamService = TestBed.get(SectionUploadService); @@ -189,7 +197,9 @@ describe('SubmissionSectionUploadComponent test suite', () => { submissionServiceStub.getSubmissionObject.and.returnValue(observableOf(submissionState)); - collectionDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection)); + collectionDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(Object.assign(new Collection(), mockCollection, { + defaultAccessConditions: createSuccessfulRemoteDataObject$(mockDefaultAccessCondition) + }))); resourcePolicyService.findByHref.and.returnValue(createSuccessfulRemoteDataObject$(mockDefaultAccessCondition)); @@ -206,7 +216,7 @@ describe('SubmissionSectionUploadComponent test suite', () => { comp.onSectionInit(); - const expectedGroupsMap = new Map([ + const expectedGroupsMap = new Map([ [mockUploadConfigResponse.accessConditionOptions[1].name, [mockGroup as any]], [mockUploadConfigResponse.accessConditionOptions[2].name, [mockGroup as any]], ]); @@ -215,6 +225,7 @@ describe('SubmissionSectionUploadComponent test suite', () => { expect(comp.collectionName).toBe(mockCollection.name); expect(comp.availableAccessConditionOptions.length).toBe(4); expect(comp.availableAccessConditionOptions).toEqual(mockUploadConfigResponse.accessConditionOptions as any); + expect(comp.required$.getValue()).toBe(true); expect(compAsAny.subs.length).toBe(2); expect(compAsAny.availableGroups.size).toBe(2); expect(compAsAny.availableGroups).toEqual(expectedGroupsMap); @@ -245,7 +256,7 @@ describe('SubmissionSectionUploadComponent test suite', () => { comp.onSectionInit(); - const expectedGroupsMap = new Map([ + const expectedGroupsMap = new Map([ [mockUploadConfigResponse.accessConditionOptions[1].name, [mockGroup as any]], [mockUploadConfigResponse.accessConditionOptions[2].name, [mockGroup as any]], ]); @@ -254,6 +265,7 @@ describe('SubmissionSectionUploadComponent test suite', () => { expect(comp.collectionName).toBe(mockCollection.name); expect(comp.availableAccessConditionOptions.length).toBe(4); expect(comp.availableAccessConditionOptions).toEqual(mockUploadConfigResponse.accessConditionOptions as any); + expect(comp.required$.getValue()).toBe(true); expect(compAsAny.subs.length).toBe(2); expect(compAsAny.availableGroups.size).toBe(2); expect(compAsAny.availableGroups).toEqual(expectedGroupsMap); @@ -263,17 +275,67 @@ describe('SubmissionSectionUploadComponent test suite', () => { }); - it('should the properly section status', () => { - bitstreamService.getUploadedFileList.and.returnValue(hot('-a-b', { + it('should properly read the section status when required is true', () => { + submissionServiceStub.getSubmissionObject.and.returnValue(observableOf(submissionState)); + + collectionDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection)); + + resourcePolicyService.findByHref.and.returnValue(createSuccessfulRemoteDataObject$(mockDefaultAccessCondition)); + + uploadsConfigService.getConfigByHref.and.returnValue(observableOf( + new ConfigData(new PageInfo(), mockUploadConfigResponse as any) + )); + + groupService.findById.and.returnValues( + createSuccessfulRemoteDataObject$(Object.assign(new Group(), mockGroup)), + createSuccessfulRemoteDataObject$(Object.assign(new Group(), mockGroup)) + ); + + bitstreamService.getUploadedFileList.and.returnValue(cold('-a-b', { a: [], b: mockUploadFiles })); + comp.onSectionInit(); + + expect(comp.required$.getValue()).toBe(true); + expect(compAsAny.getSectionStatus()).toBeObservable(cold('-c-d', { c: false, d: true })); }); + + it('should properly read the section status when required is false', () => { + submissionServiceStub.getSubmissionObject.and.returnValue(observableOf(submissionState)); + + collectionDataService.findById.and.returnValue(createSuccessfulRemoteDataObject$(mockCollection)); + + resourcePolicyService.findByHref.and.returnValue(createSuccessfulRemoteDataObject$(mockDefaultAccessCondition)); + + uploadsConfigService.getConfigByHref.and.returnValue(observableOf( + new ConfigData(new PageInfo(), mockUploadConfigResponseNotRequired as any) + )); + + groupService.findById.and.returnValues( + createSuccessfulRemoteDataObject$(Object.assign(new Group(), mockGroup)), + createSuccessfulRemoteDataObject$(Object.assign(new Group(), mockGroup)) + ); + + bitstreamService.getUploadedFileList.and.returnValue(cold('-a-b', { + a: [], + b: mockUploadFiles + })); + + comp.onSectionInit(); + + expect(comp.required$.getValue()).toBe(false); + + expect(compAsAny.getSectionStatus()).toBeObservable(cold('-c-d', { + c: true, + d: true + })); + }); }); }); diff --git a/src/app/submission/sections/upload/section-upload.component.ts b/src/app/submission/sections/upload/section-upload.component.ts index 9dbd1079f4..0bdb1a58f5 100644 --- a/src/app/submission/sections/upload/section-upload.component.ts +++ b/src/app/submission/sections/upload/section-upload.component.ts @@ -1,13 +1,13 @@ import { ChangeDetectorRef, Component, Inject } from '@angular/core'; -import { combineLatest, Observable, Subscription } from 'rxjs'; +import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, Subscription} from 'rxjs'; import { distinctUntilChanged, filter, find, flatMap, map, reduce, take, tap } from 'rxjs/operators'; import { SectionModelComponent } from '../models/section.model'; import { hasValue, isNotEmpty, isNotUndefined, isUndefined } from '../../../shared/empty.util'; import { SectionUploadService } from './section-upload.service'; import { CollectionDataService } from '../../../core/data/collection-data.service'; -import { GroupEpersonService } from '../../../core/eperson/group-eperson.service'; +import { GroupDataService } from '../../../core/eperson/group-data.service'; import { ResourcePolicyService } from '../../../core/data/resource-policy.service'; import { SubmissionUploadsConfigService } from '../../../core/config/submission-uploads-config.service'; import { SubmissionUploadsModel } from '../../../core/config/models/config-submission-uploads.model'; @@ -22,7 +22,6 @@ import { Group } from '../../../core/eperson/models/group.model'; import { SectionsService } from '../sections.service'; import { SubmissionService } from '../../submission.service'; import { Collection } from '../../../core/shared/collection.model'; -import { ResourcePolicy } from '../../../core/shared/resource-policy.model'; import { AccessConditionOption } from '../../../core/config/models/config-access-condition-option.model'; import { PaginatedList } from '../../../core/data/paginated-list'; @@ -95,7 +94,7 @@ export class SubmissionSectionUploadComponent extends SectionModelComponent { public configMetadataForm$: Observable; /** - * List of available access conditions that could be setted to files + * List of available access conditions that could be set to files */ public availableAccessConditionOptions: AccessConditionOption[]; // List of accessConditions that an user can select @@ -104,6 +103,12 @@ export class SubmissionSectionUploadComponent extends SectionModelComponent { */ protected availableGroups: Map; // Groups for any policy + /** + * Is the upload required + * @type {boolean} + */ + public required$ = new BehaviorSubject(true); + /** * Array to track all subscriptions and unsubscribe them onDestroy * @type {Array} @@ -116,7 +121,7 @@ export class SubmissionSectionUploadComponent extends SectionModelComponent { * @param {SectionUploadService} bitstreamService * @param {ChangeDetectorRef} changeDetectorRef * @param {CollectionDataService} collectionDataService - * @param {GroupEpersonService} groupService + * @param {GroupDataService} groupService * @param {ResourcePolicyService} resourcePolicyService * @param {SectionsService} sectionService * @param {SubmissionService} submissionService @@ -127,7 +132,7 @@ export class SubmissionSectionUploadComponent extends SectionModelComponent { constructor(private bitstreamService: SectionUploadService, private changeDetectorRef: ChangeDetectorRef, private collectionDataService: CollectionDataService, - private groupService: GroupEpersonService, + private groupService: GroupDataService, private resourcePolicyService: ResourcePolicyService, protected sectionService: SectionsService, private submissionService: SubmissionService, @@ -157,9 +162,10 @@ 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 + (collectionRemoteData.payload as any)._links.defaultAccessConditions.href ); }), filter((defaultAccessConditionsRemoteData: RemoteData) => @@ -169,9 +175,10 @@ 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); this.availableAccessConditionOptions = isNotEmpty(config.accessConditionOptions) ? config.accessConditionOptions : []; this.collectionPolicyType = this.availableAccessConditionOptions.length > 0 @@ -196,7 +203,7 @@ export class SubmissionSectionUploadComponent extends SectionModelComponent { mapGroups$.push( this.groupService.findById(accessCondition.selectGroupUUID).pipe( find((rd: RemoteData) => !rd.isResponsePending && rd.hasSucceeded), - flatMap((group: RemoteData) => group.payload.groups), + flatMap((group: RemoteData) => group.payload.subgroups), find((rd: RemoteData>) => !rd.isResponsePending && rd.hasSucceeded), map((rd: RemoteData>) => ({ accessCondition: accessCondition.name, @@ -221,7 +228,7 @@ export class SubmissionSectionUploadComponent extends SectionModelComponent { }), // retrieve submission's bitstreams from state - combineLatest(this.configMetadataForm$, + observableCombineLatest(this.configMetadataForm$, this.bitstreamService.getUploadedFileList(this.submissionId, this.sectionData.id)).pipe( filter(([configMetadataForm, fileList]: [SubmissionFormsModel, any[]]) => { return isNotEmpty(configMetadataForm) && isNotUndefined(fileList) @@ -273,8 +280,13 @@ export class SubmissionSectionUploadComponent extends SectionModelComponent { * the section status */ protected getSectionStatus(): Observable { - return this.bitstreamService.getUploadedFileList(this.submissionId, this.sectionData.id).pipe( - map((fileList: any[]) => (isNotUndefined(fileList) && fileList.length > 0))); + // if not mandatory, always true + // if mandatory, at least one file is required + return observableCombineLatest(this.required$, + this.bitstreamService.getUploadedFileList(this.submissionId, this.sectionData.id), + (required,fileList: any[]) => { + return (!required || (isNotUndefined(fileList) && fileList.length > 0)); + }); } /** 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/app/thumbnail/thumbnail.component.html b/src/app/thumbnail/thumbnail.component.html index 87fd0251f5..dbf8f6732c 100644 --- a/src/app/thumbnail/thumbnail.component.html +++ b/src/app/thumbnail/thumbnail.component.html @@ -1,4 +1,4 @@
- +
diff --git a/src/app/thumbnail/thumbnail.component.spec.ts b/src/app/thumbnail/thumbnail.component.spec.ts index f2be55d52c..c4258aceb9 100644 --- a/src/app/thumbnail/thumbnail.component.spec.ts +++ b/src/app/thumbnail/thumbnail.component.spec.ts @@ -1,11 +1,11 @@ -import { ComponentFixture, TestBed, async } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; import { DebugElement } from '@angular/core'; - -import { ThumbnailComponent } from './thumbnail.component'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { Bitstream } from '../core/shared/bitstream.model'; import { SafeUrlPipe } from '../shared/utils/safe-url-pipe'; +import { THUMBNAIL_PLACEHOLDER, ThumbnailComponent } from './thumbnail.component'; + describe('ThumbnailComponent', () => { let comp: ThumbnailComponent; let fixture: ComponentFixture; @@ -25,18 +25,37 @@ describe('ThumbnailComponent', () => { el = de.nativeElement; }); - it('should display image', () => { - comp.thumbnail = new Bitstream(); - comp.thumbnail.content = 'test.url'; - fixture.detectChanges(); - const image: HTMLElement = de.query(By.css('img')).nativeElement; - expect(image.getAttribute('src')).toBe(comp.thumbnail.content); + describe('when the thumbnail exists', () => { + it('should display an image', () => { + const thumbnail = new Bitstream(); + thumbnail._links = { + self: { href: 'self.url' }, + bundle: { href: 'bundle.url' }, + format: { href: 'format.url' }, + content: { href: 'content.url' }, + }; + comp.thumbnail = thumbnail; + fixture.detectChanges(); + const image: HTMLElement = de.query(By.css('img')).nativeElement; + expect(image.getAttribute('src')).toBe(comp.thumbnail._links.content.href); + }); }); - - it('should display placeholder', () => { - fixture.detectChanges(); - const image: HTMLElement = de.query(By.css('img')).nativeElement; - expect(image.getAttribute('src')).toBe(comp.defaultImage); + describe(`when the thumbnail doesn't exist`, () => { + describe('and there is a default image', () => { + it('should display the default image', () => { + comp.src = 'http://bit.stream'; + comp.defaultImage = 'http://default.img'; + comp.errorHandler(); + expect(comp.src).toBe(comp.defaultImage); + }); + }); + describe('and there is no default image', () => { + it('should display the placeholder', () => { + comp.src = 'http://default.img'; + comp.defaultImage = 'http://default.img'; + comp.errorHandler(); + expect(comp.src).toBe(THUMBNAIL_PLACEHOLDER); + }) + }); }); - }); diff --git a/src/app/thumbnail/thumbnail.component.ts b/src/app/thumbnail/thumbnail.component.ts index e31e907b47..2bbd2bb2da 100644 --- a/src/app/thumbnail/thumbnail.component.ts +++ b/src/app/thumbnail/thumbnail.component.ts @@ -2,12 +2,16 @@ import { Component, Input, OnInit } from '@angular/core'; import { Bitstream } from '../core/shared/bitstream.model'; import { hasValue } from '../shared/empty.util'; +/** + * A fallback placeholder image as a base64 string + */ +export const THUMBNAIL_PLACEHOLDER = 'data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2293%22%20height%3D%22120%22%20viewBox%3D%220%200%2093%20120%22%20preserveAspectRatio%3D%22none%22%3E%3C!--%0ASource%20URL%3A%20holder.js%2F93x120%3Ftext%3DNo%20Thumbnail%0ACreated%20with%20Holder.js%202.8.2.%0ALearn%20more%20at%20http%3A%2F%2Fholderjs.com%0A(c)%202012-2015%20Ivan%20Malopinsky%20-%20http%3A%2F%2Fimsky.co%0A--%3E%3Cdefs%3E%3Cstyle%20type%3D%22text%2Fcss%22%3E%3C!%5BCDATA%5B%23holder_1543e460b05%20text%20%7B%20fill%3A%23AAAAAA%3Bfont-weight%3Abold%3Bfont-family%3AArial%2C%20Helvetica%2C%20Open%20Sans%2C%20sans-serif%2C%20monospace%3Bfont-size%3A10pt%20%7D%20%5D%5D%3E%3C%2Fstyle%3E%3C%2Fdefs%3E%3Cg%20id%3D%22holder_1543e460b05%22%3E%3Crect%20width%3D%2293%22%20height%3D%22120%22%20fill%3D%22%23FFFFFF%22%2F%3E%3Cg%3E%3Ctext%20x%3D%2235.6171875%22%20y%3D%2257%22%3ENo%3C%2Ftext%3E%3Ctext%20x%3D%2210.8125%22%20y%3D%2272%22%3EThumbnail%3C%2Ftext%3E%3C%2Fg%3E%3C%2Fg%3E%3C%2Fsvg%3E'; + /** * This component renders a given Bitstream as a thumbnail. * One input parameter of type Bitstream is expected. * If no Bitstream is provided, a holderjs image will be rendered instead. */ - @Component({ selector: 'ds-thumbnail', styleUrls: ['./thumbnail.component.scss'], @@ -15,24 +19,43 @@ import { hasValue } from '../shared/empty.util'; }) export class ThumbnailComponent implements OnInit { + /** + * The thumbnail Bitstream + */ @Input() thumbnail: Bitstream; /** - * The default 'holder.js' image + * The default image, used if the thumbnail isn't set or can't be downloaded */ - @Input() defaultImage? = 'data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2293%22%20height%3D%22120%22%20viewBox%3D%220%200%2093%20120%22%20preserveAspectRatio%3D%22none%22%3E%3C!--%0ASource%20URL%3A%20holder.js%2F93x120%3Ftext%3DNo%20Thumbnail%0ACreated%20with%20Holder.js%202.8.2.%0ALearn%20more%20at%20http%3A%2F%2Fholderjs.com%0A(c)%202012-2015%20Ivan%20Malopinsky%20-%20http%3A%2F%2Fimsky.co%0A--%3E%3Cdefs%3E%3Cstyle%20type%3D%22text%2Fcss%22%3E%3C!%5BCDATA%5B%23holder_1543e460b05%20text%20%7B%20fill%3A%23AAAAAA%3Bfont-weight%3Abold%3Bfont-family%3AArial%2C%20Helvetica%2C%20Open%20Sans%2C%20sans-serif%2C%20monospace%3Bfont-size%3A10pt%20%7D%20%5D%5D%3E%3C%2Fstyle%3E%3C%2Fdefs%3E%3Cg%20id%3D%22holder_1543e460b05%22%3E%3Crect%20width%3D%2293%22%20height%3D%22120%22%20fill%3D%22%23FFFFFF%22%2F%3E%3Cg%3E%3Ctext%20x%3D%2235.6171875%22%20y%3D%2257%22%3ENo%3C%2Ftext%3E%3Ctext%20x%3D%2210.8125%22%20y%3D%2272%22%3EThumbnail%3C%2Ftext%3E%3C%2Fg%3E%3C%2Fg%3E%3C%2Fsvg%3E'; + @Input() defaultImage? = THUMBNAIL_PLACEHOLDER; + /** + * The src attribute used in the template to render the image. + */ src: string; - errorHandler(event) { - event.currentTarget.src = this.defaultImage; - } + /** + * Initialize the thumbnail. + * Use a default image if no actual image is available. + */ ngOnInit(): void { - if (hasValue(this.thumbnail) && this.thumbnail.content) { - this.src = this.thumbnail.content; - } else { - this.src = this.defaultImage - } + if (hasValue(this.thumbnail) && hasValue(this.thumbnail._links) && hasValue(this.thumbnail._links.content) && this.thumbnail._links.content.href) { + this.src = this.thumbnail._links.content.href; + } else { + this.src = this.defaultImage + } } + /** + * Handle image download errors. + * If the image can't be found, use the defaultImage instead. + * If that also can't be found, use the base64 placeholder. + */ + errorHandler() { + if (this.src !== this.defaultImage) { + this.src = this.defaultImage; + } else { + this.src = THUMBNAIL_PLACEHOLDER; + } + } } 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/collection-page-config.interface.ts b/src/config/collection-page-config.interface.ts new file mode 100644 index 0000000000..b0103fd176 --- /dev/null +++ b/src/config/collection-page-config.interface.ts @@ -0,0 +1,7 @@ +import { Config } from './config.interface'; + +export interface CollectionPageConfig extends Config { + edit: { + undoTimeout: number; + } +} diff --git a/src/config/global-config.interface.ts b/src/config/global-config.interface.ts index 22b4b0500f..f361e6def6 100644 --- a/src/config/global-config.interface.ts +++ b/src/config/global-config.interface.ts @@ -8,13 +8,16 @@ import { FormConfig } from './form-config.interfaces'; import {LangConfig} from './lang-config.interface'; 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; @@ -26,5 +29,6 @@ export interface GlobalConfig extends Config { languages: LangConfig[]; browseBy: BrowseByConfig; item: ItemPageConfig; + collection: CollectionPageConfig; themes: Theme[]; } diff --git a/src/modules/app/browser-app.module.ts b/src/modules/app/browser-app.module.ts index 0dbe4f58fe..34d8fb18a7 100644 --- a/src/modules/app/browser-app.module.ts +++ b/src/modules/app/browser-app.module.ts @@ -18,7 +18,7 @@ import { DSpaceTransferState } from '../transfer-state/dspace-transfer-state.ser import { ClientCookieService } from '../../app/core/services/client-cookie.service'; import { CookieService } from '../../app/core/services/cookie.service'; import { AuthService } from '../../app/core/auth/auth.service'; -import { Angulartics2Module } from 'angulartics2'; +import { Angulartics2RouterlessModule } from 'angulartics2/routerlessmodule'; import { SubmissionService } from '../../app/submission/submission.service'; import { StatisticsModule } from '../../app/statistics/statistics.module'; @@ -48,7 +48,7 @@ export function getRequest(transferState: TransferState): any { IdlePreload }), StatisticsModule.forRoot(), - Angulartics2Module.forRoot(), + Angulartics2RouterlessModule.forRoot(), BrowserAnimationsModule, DSpaceBrowserTransferStateModule, TranslateModule.forRoot({ diff --git a/src/modules/app/server-app.module.ts b/src/modules/app/server-app.module.ts index 286e878d9b..4011bb8d37 100644 --- a/src/modules/app/server-app.module.ts +++ b/src/modules/app/server-app.module.ts @@ -23,7 +23,7 @@ import { AngularticsMock } from '../../app/shared/mocks/mock-angulartics.service import { SubmissionService } from '../../app/submission/submission.service'; import { ServerSubmissionService } from '../../app/submission/server-submission.service'; import { Angulartics2DSpace } from '../../app/statistics/angulartics/dspace-provider'; -import { Angulartics2Module } from 'angulartics2'; +import { Angulartics2RouterlessModule } from 'angulartics2/routerlessmodule'; export function createTranslateLoader() { return new TranslateJson5UniversalLoader('dist/assets/i18n/', '.json5'); @@ -47,7 +47,7 @@ export function createTranslateLoader() { deps: [] } }), - Angulartics2Module.forRoot(), + Angulartics2RouterlessModule.forRoot(), ServerModule, AppModule ], diff --git a/themes/mantis/app/+item-page/simple/item-types/publication/publication.component.html b/themes/mantis/app/+item-page/simple/item-types/publication/publication.component.html index 35dc903432..adecd9e1af 100644 --- a/themes/mantis/app/+item-page/simple/item-types/publication/publication.component.html +++ b/themes/mantis/app/+item-page/simple/item-types/publication/publication.component.html @@ -4,7 +4,7 @@ a
diff --git a/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html b/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html index 9fa80d9c3c..78a0eba13e 100644 --- a/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html +++ b/themes/mantis/app/entity-groups/journal-entities/item-pages/journal-issue/journal-issue.component.html @@ -4,7 +4,7 @@
- +
- +
- +
-
diff --git a/themes/mantis/app/entity-groups/research-entities/item-pages/person/person.component.html b/themes/mantis/app/entity-groups/research-entities/item-pages/person/person.component.html index 1679f9354d..dbcb76a292 100644 --- a/themes/mantis/app/entity-groups/research-entities/item-pages/person/person.component.html +++ b/themes/mantis/app/entity-groups/research-entities/item-pages/person/person.component.html @@ -4,7 +4,7 @@
-
diff --git a/themes/mantis/app/entity-groups/research-entities/item-pages/project/project.component.html b/themes/mantis/app/entity-groups/research-entities/item-pages/project/project.component.html index 31ba79a158..b31353ef76 100644 --- a/themes/mantis/app/entity-groups/research-entities/item-pages/project/project.component.html +++ b/themes/mantis/app/entity-groups/research-entities/item-pages/project/project.component.html @@ -4,7 +4,7 @@
-
diff --git a/webpack/webpack.server.js b/webpack/webpack.server.js index 5e80a286a0..6f529ed791 100644 --- a/webpack/webpack.server.js +++ b/webpack/webpack.server.js @@ -20,7 +20,7 @@ module.exports = (env) => { /@ng/, /angular2-text-mask/, /ng2-file-upload/, - /angular-sortablejs/, + /ngx-sortablejs/, /sortablejs/, /ngx/] })], diff --git a/yarn.lock b/yarn.lock index 2d34ac734d..7035bb60c4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,84 +2,95 @@ # yarn lockfile v1 -"@angular-devkit/architect@0.13.9": - version "0.13.9" - resolved "https://registry.yarnpkg.com/@angular-devkit/architect/-/architect-0.13.9.tgz#8bbca4b968fccbf88fc2f86542cbee09e1256e1f" - integrity sha512-EAFtCs9dsGhpMRC45PoYsrkiExpWz9Ax15qXfzwdDRacz5DmdOVt+QpkLW1beUOwiyj/bhFyj23eaONK2RTn/w== +"@angular-devkit/architect@0.803.25": + version "0.803.25" + resolved "https://registry.yarnpkg.com/@angular-devkit/architect/-/architect-0.803.25.tgz#06d109b3b24a080f0bac7374c5328b6a7b886f06" + integrity sha512-usV/zEncKCKQuF6AD3pRU6N5i5fbaAux/qZb+nbOz9/2G5jrXwe5sH+y3vxbgqB83e3LqusEQCTu7/tfg6LwZg== dependencies: - "@angular-devkit/core" "7.3.9" - rxjs "6.3.3" + "@angular-devkit/core" "8.3.25" + rxjs "6.4.0" -"@angular-devkit/build-angular@^0.13.5": - version "0.13.9" - resolved "https://registry.yarnpkg.com/@angular-devkit/build-angular/-/build-angular-0.13.9.tgz#92ef7b55a1aa055b2f5c8ffed4bdb04df86db678" - integrity sha512-onh07LhdxotDFjja0KKsDWNCwgpM/ymuRr5h0e+vT4AgklP2Uioz1CpzVOgxPIKkdVdGR9QgDinVsWAmY90J8g== +"@angular-devkit/build-angular@^0.803.25": + version "0.803.25" + resolved "https://registry.yarnpkg.com/@angular-devkit/build-angular/-/build-angular-0.803.25.tgz#c630fda1d85b720a0f76211edbd475a8399fbbbb" + integrity sha512-WY0E7NgXuog3phhz5ZdutZPWQ9nbOr+omGN5KI1e8MZs1sJO4xkyaGRT8zOulkogkqJ2NboTBq3j9uSbZkcYeg== dependencies: - "@angular-devkit/architect" "0.13.9" - "@angular-devkit/build-optimizer" "0.13.9" - "@angular-devkit/build-webpack" "0.13.9" - "@angular-devkit/core" "7.3.9" - "@ngtools/webpack" "7.3.9" - ajv "6.9.1" - autoprefixer "9.4.6" - circular-dependency-plugin "5.0.2" + "@angular-devkit/architect" "0.803.25" + "@angular-devkit/build-optimizer" "0.803.25" + "@angular-devkit/build-webpack" "0.803.25" + "@angular-devkit/core" "8.3.25" + "@babel/core" "7.8.3" + "@babel/preset-env" "7.8.3" + "@ngtools/webpack" "8.3.25" + ajv "6.10.2" + autoprefixer "9.6.1" + browserslist "4.8.6" + cacache "12.0.2" + caniuse-lite "1.0.30001024" + circular-dependency-plugin "5.2.0" clean-css "4.2.1" - copy-webpack-plugin "4.6.0" - file-loader "3.0.1" - glob "7.1.3" - istanbul-instrumenter-loader "3.0.1" - karma-source-map-support "1.3.0" + copy-webpack-plugin "5.1.1" + core-js "3.6.4" + coverage-istanbul-loader "2.0.3" + file-loader "4.2.0" + find-cache-dir "3.0.0" + glob "7.1.4" + jest-worker "24.9.0" + karma-source-map-support "1.4.0" less "3.9.0" - less-loader "4.1.0" - license-webpack-plugin "2.1.0" + less-loader "5.0.0" + license-webpack-plugin "2.1.2" loader-utils "1.2.3" - mini-css-extract-plugin "0.5.0" + mini-css-extract-plugin "0.8.0" minimatch "3.0.4" - open "6.0.0" + open "6.4.0" parse5 "4.0.0" - postcss "7.0.14" + postcss "7.0.17" postcss-import "12.0.1" postcss-loader "3.0.0" - raw-loader "1.0.0" - rxjs "6.3.3" - sass-loader "7.1.0" - semver "5.6.0" + raw-loader "3.1.0" + regenerator-runtime "0.13.3" + rxjs "6.4.0" + sass "1.22.9" + sass-loader "7.2.0" + semver "6.3.0" + source-map "0.7.3" source-map-loader "0.2.4" - source-map-support "0.5.10" + source-map-support "0.5.13" speed-measure-webpack-plugin "1.3.1" - stats-webpack-plugin "0.7.0" - style-loader "0.23.1" + style-loader "1.0.0" stylus "0.54.5" stylus-loader "3.0.2" - terser-webpack-plugin "1.2.2" - tree-kill "1.2.1" - webpack "4.29.0" - webpack-dev-middleware "3.5.1" - webpack-dev-server "3.1.14" + terser "4.6.3" + terser-webpack-plugin "1.4.3" + tree-kill "1.2.2" + webpack "4.39.2" + webpack-dev-middleware "3.7.2" + webpack-dev-server "3.9.0" webpack-merge "4.2.1" - webpack-sources "1.3.0" + webpack-sources "1.4.3" webpack-subresource-integrity "1.1.0-rc.6" - optionalDependencies: - node-sass "4.12.0" + worker-plugin "3.2.0" -"@angular-devkit/build-optimizer@0.13.9": - version "0.13.9" - resolved "https://registry.yarnpkg.com/@angular-devkit/build-optimizer/-/build-optimizer-0.13.9.tgz#05a25ca7743876987158881585c55dfc478b95bd" - integrity sha512-GQtCntthQHSBv5l1ZY5p00JOECb/WcE1qUBo5kFjp84z0fszDkhOy52M1kcWCX4PFzJaY4DKk58hbUE/2UN0jw== +"@angular-devkit/build-optimizer@0.803.25": + version "0.803.25" + resolved "https://registry.yarnpkg.com/@angular-devkit/build-optimizer/-/build-optimizer-0.803.25.tgz#83aedee3cbe15f4ec7f777dc028f2669e0ff4439" + integrity sha512-MiQimuEs8QeM3xo7bR3Yk1OWHHlp2pGCc2GLUMIcWhKqM+QjoRky0HoGoBazbznx292l+xjFjANvPEKbqJ2v7Q== dependencies: loader-utils "1.2.3" - source-map "0.5.6" - typescript "3.2.4" - webpack-sources "1.3.0" + source-map "0.7.3" + tslib "1.10.0" + typescript "3.5.3" + webpack-sources "1.4.3" -"@angular-devkit/build-webpack@0.13.9": - version "0.13.9" - resolved "https://registry.yarnpkg.com/@angular-devkit/build-webpack/-/build-webpack-0.13.9.tgz#9fa091d778db752c539e1c585e21ba47d7054672" - integrity sha512-6ypu6pzNmQxzATF4rTWEhGSl5hyGQ8a/3aCZF/ux+XGc3d4hi2HW+NWlDm1UEna6ZjNtgEPlgfP4q8BKrjRmfA== +"@angular-devkit/build-webpack@0.803.25": + version "0.803.25" + resolved "https://registry.yarnpkg.com/@angular-devkit/build-webpack/-/build-webpack-0.803.25.tgz#7a0648920de1c51d30447cf369929d491e267f9c" + integrity sha512-WR7HWJIWL6TB3WHG7ZFn8s0z3WlojeQlod75UIKl5i+f4OU90kp8kxcoH5G6OCXu56x5w40oIi1ve5ljjWSJkw== dependencies: - "@angular-devkit/architect" "0.13.9" - "@angular-devkit/core" "7.3.9" - rxjs "6.3.3" + "@angular-devkit/architect" "0.803.25" + "@angular-devkit/core" "8.3.25" + rxjs "6.4.0" "@angular-devkit/core@0.7.5": version "0.7.5" @@ -91,15 +102,15 @@ rxjs "^6.0.0" source-map "^0.5.6" -"@angular-devkit/core@7.3.9": - version "7.3.9" - resolved "https://registry.yarnpkg.com/@angular-devkit/core/-/core-7.3.9.tgz#bef2aaa0be7219c546fb99ea0ba9dd3a6dcd288a" - integrity sha512-SaxD+nKFW3iCBKsxNR7+66J30EexW/y7tm8m5AvUH+GwSAgIj0ZYmRUzFEPggcaLVA4WnE/YWqIXZMJW5dT7gw== +"@angular-devkit/core@8.3.25": + version "8.3.25" + resolved "https://registry.yarnpkg.com/@angular-devkit/core/-/core-8.3.25.tgz#8133a18be811424f10a10f37c712165b0f69f3fc" + integrity sha512-l7Gqy1tMrTpRmPVlovcFX8UA3mtXRlgO8kcSsbJ9MKRKNTCcxlfsWEYY5igyDBUVh6ADkgSIu0nuk31ZGTe0lw== dependencies: - ajv "6.9.1" - chokidar "2.0.4" + ajv "6.10.2" fast-json-stable-stringify "2.0.0" - rxjs "6.3.3" + magic-string "0.25.3" + rxjs "6.4.0" source-map "0.7.3" "@angular-devkit/schematics@0.7.5": @@ -110,60 +121,67 @@ "@angular-devkit/core" "0.7.5" rxjs "^6.0.0" -"@angular-devkit/schematics@7.3.9": - version "7.3.9" - resolved "https://registry.yarnpkg.com/@angular-devkit/schematics/-/schematics-7.3.9.tgz#4fe7bc878b116b157a3adf00583c28c951215877" - integrity sha512-xzROGCYp7aQbeJ3V6YC0MND7wKEAdWqmm/GaCufEk0dDS8ZGe0sQhcM2oBRa2nQqGQNeThFIH51kx+FayrJP0w== +"@angular-devkit/schematics@8.3.25": + version "8.3.25" + resolved "https://registry.yarnpkg.com/@angular-devkit/schematics/-/schematics-8.3.25.tgz#692eaa0fc14bc09c315d93966c781a97ca524f77" + integrity sha512-/p1MkfursfLy+JRGXlJGPEmX55lrFCsR/2khWAVXZcMaFR3QlR/b6/zvB8I2pHFfr0/XWnYTT/BsF7rJjO3RmA== dependencies: - "@angular-devkit/core" "7.3.9" - rxjs "6.3.3" + "@angular-devkit/core" "8.3.25" + rxjs "6.4.0" -"@angular/animations@^7.2.15": - version "7.2.15" - resolved "https://registry.yarnpkg.com/@angular/animations/-/animations-7.2.15.tgz#980c1f523a79d4b7cb44508f57fba06f2e0872fa" - integrity sha512-8oBt3HLgd2+kyJHUgsd7OzKCCss67t2sch15XNoIWlOLfxclqU+EfFE6t/vCzpT8/+lpZS6LU9ZrTnb+UBj5jg== +"@angular/animations@^8.2.14": + version "8.2.14" + resolved "https://registry.yarnpkg.com/@angular/animations/-/animations-8.2.14.tgz#76736b21e56165e6ca4925fb69605bdcc56aba7d" + integrity sha512-3Vc9TnNpKdtvKIXcWDFINSsnwgEMiDmLzjceWg1iYKwpeZGQahUXPoesLwQazBMmxJzQiA4HOMj0TTXKZ+Jzkg== dependencies: tslib "^1.9.0" -"@angular/cdk@7.3.7": - version "7.3.7" - resolved "https://registry.yarnpkg.com/@angular/cdk/-/cdk-7.3.7.tgz#ce1ad53ba04beb9c8e950acc5691ea0143753764" - integrity sha512-xbXxhHHKGkVuW6K7pzPmvpJXIwpl0ykBnvA2g+/7Sgy5Pd35wCC+UtHD9RYczDM/mkygNxMQtagyCErwFnDtQA== +"@angular/cdk@8.2.3": + version "8.2.3" + resolved "https://registry.yarnpkg.com/@angular/cdk/-/cdk-8.2.3.tgz#16b96ffa935cbf5a646757ecaf2b19c434678f72" + integrity sha512-ZwO5Sn720RA2YvBqud0JAHkZXjmjxM0yNzCO8RVtRE9i8Gl26Wk0j0nQeJkVm4zwv2QO8MwbKUKGTMt8evsokA== dependencies: tslib "^1.7.1" optionalDependencies: parse5 "^5.0.0" -"@angular/cli@^7.3.5": - version "7.3.9" - resolved "https://registry.yarnpkg.com/@angular/cli/-/cli-7.3.9.tgz#0366b5a66654c1f02ab2f3a9f15ebde446d506a4" - integrity sha512-7oJj7CKDlFUbQav1x1CV4xKKcbt0pnxY4unKcm7Q1tVXhu8bU2bc3cDA0aJnbofcYb6TJcd/C2qHgCt78q7edA== +"@angular/cli@^8.3.25": + version "8.3.25" + resolved "https://registry.yarnpkg.com/@angular/cli/-/cli-8.3.25.tgz#2802291f83a88f334336a1482c8ee63a69cabad7" + integrity sha512-CPJI5nnbBvvyBUFwOHfRXy/KVwsiYlcbDAeIk1klcjQjbVFYZbnY0iAhNupy9j7rPQhb7jle5oslU3TLfbqOTQ== dependencies: - "@angular-devkit/architect" "0.13.9" - "@angular-devkit/core" "7.3.9" - "@angular-devkit/schematics" "7.3.9" - "@schematics/angular" "7.3.9" - "@schematics/update" "0.13.9" + "@angular-devkit/architect" "0.803.25" + "@angular-devkit/core" "8.3.25" + "@angular-devkit/schematics" "8.3.25" + "@schematics/angular" "8.3.25" + "@schematics/update" "0.803.25" "@yarnpkg/lockfile" "1.1.0" + ansi-colors "4.1.1" + debug "^4.1.1" ini "1.3.5" - inquirer "6.2.1" + inquirer "6.5.1" npm-package-arg "6.1.0" - open "6.0.0" - pacote "9.4.0" - semver "5.6.0" + npm-pick-manifest "3.0.2" + open "6.4.0" + pacote "9.5.5" + read-package-tree "5.3.1" + rimraf "3.0.0" + semver "6.3.0" symbol-observable "1.2.0" + universal-analytics "^0.4.20" + uuid "^3.3.2" -"@angular/common@^7.2.15": - version "7.2.15" - resolved "https://registry.yarnpkg.com/@angular/common/-/common-7.2.15.tgz#e6c2f6913cdc49f87adcaabc30604e721561374b" - integrity sha512-2b5JY2HWVHCf3D1GZjmde7jdAXSTXkYtmjLtA9tQkjOOTr80eHpNSujQqnzb97dk9VT9OjfjqTQd7K3pxZz8jw== +"@angular/common@^8.2.14": + version "8.2.14" + resolved "https://registry.yarnpkg.com/@angular/common/-/common-8.2.14.tgz#027e52b2951c14082d6e3af1a4ffa1356220e439" + integrity sha512-Qmt+aX2quUW54kaNT7QH7WGXnFxr/cC2C6sf5SW5SdkZfDQSiz8IaItvieZfXVQUbBOQKFRJ7TlSkt0jI/yjvw== dependencies: tslib "^1.9.0" -"@angular/compiler-cli@^7.2.15": - version "7.2.15" - resolved "https://registry.yarnpkg.com/@angular/compiler-cli/-/compiler-cli-7.2.15.tgz#25cc3a6556ba726d00c4992ad894f8db203f4fbc" - integrity sha512-+AsfyKawmj/sa+m4Pz8VSRFbCfx/3IOjAuuEjhopbyr154YpPDSu8NTbcwzq3yfbVcPwK4/4exmbQzpsndaCTg== +"@angular/compiler-cli@^8.2.14": + version "8.2.14" + resolved "https://registry.yarnpkg.com/@angular/compiler-cli/-/compiler-cli-8.2.14.tgz#1997bec04a6b9d022954e5747505fe8906994594" + integrity sha512-XDrTyrlIZM+0NquVT+Kbg5bn48AaWFT+B3bAT288PENrTdkuxuF9AhjFRZj8jnMdmaE4O2rioEkXBtl6z3zptA== dependencies: canonical-path "1.0.0" chokidar "^2.1.1" @@ -172,66 +190,58 @@ magic-string "^0.25.0" minimist "^1.2.0" reflect-metadata "^0.1.2" - shelljs "^0.8.1" source-map "^0.6.1" tslib "^1.9.0" - yargs "9.0.1" + yargs "13.1.0" -"@angular/compiler@^7.2.15": - version "7.2.15" - resolved "https://registry.yarnpkg.com/@angular/compiler/-/compiler-7.2.15.tgz#9698dac49dbb46956f0b8a6280580025ea7ab04e" - integrity sha512-5yb4NcLk8GuXkYf7Dcor4XkGueYp4dgihzDmMjYDUrV0NPhubKlr+SwGtLOtzgRBWJ1I2bO0S3zwa0q0OgIPOw== +"@angular/compiler@^8.2.14": + version "8.2.14" + resolved "https://registry.yarnpkg.com/@angular/compiler/-/compiler-8.2.14.tgz#46db7a9d1c17f236126518ff26480c160d5a6183" + integrity sha512-ABZO4E7eeFA1QyJ2trDezxeQM5ZFa1dXw1Mpl/+1vuXDKNjJgNyWYwKp/NwRkLmrsuV0yv4UDCDe4kJOGbPKnw== dependencies: tslib "^1.9.0" -"@angular/core@^7.2.15": - version "7.2.15" - resolved "https://registry.yarnpkg.com/@angular/core/-/core-7.2.15.tgz#c00d4be0ebe95b70f7631154169509cc97934e9a" - integrity sha512-XsuYm0jEU/mOqwDOk2utThv8J9kESkAerfuCHClE9rB2TtHUOGCfekF7lJWqjjypu6/J9ygoPFo7hdAE058ZGg== +"@angular/core@^8.2.14": + version "8.2.14" + resolved "https://registry.yarnpkg.com/@angular/core/-/core-8.2.14.tgz#35566f5b19480369229477e7e0e0fde740bd5204" + integrity sha512-zeePkigi+hPh3rN7yoNENG/YUBUsIvUXdxx+AZq+QPaFeKEA2FBSrKn36ojHFrdJUjKzl0lPMEiGC2b6a6bo6g== dependencies: tslib "^1.9.0" -"@angular/forms@^7.2.15": - version "7.2.15" - resolved "https://registry.yarnpkg.com/@angular/forms/-/forms-7.2.15.tgz#6b6e10b5f4687b6be3081abcc02a055b3ceeb6d8" - integrity sha512-p0kcIQLtBBC1qeTA6M3nOuXf/k91E80FKquVM9zEsO2kDjI0oZJVfFYL2UMov5samlJOPN+t6lRHEIUa7ApPsw== +"@angular/forms@^8.2.14": + version "8.2.14" + resolved "https://registry.yarnpkg.com/@angular/forms/-/forms-8.2.14.tgz#7d357c346a3884881beb044c50ec4a09d3d7ee8e" + integrity sha512-zhyKL3CFIqcyHJ/TQF/h1OZztK611a6rxuPHCrt/5Sn1SuBTJJQ1pPTkOYIDy6IrCrtyANc8qB6P17Mao71DNQ== dependencies: tslib "^1.9.0" -"@angular/http@^7.2.15": - version "7.2.15" - resolved "https://registry.yarnpkg.com/@angular/http/-/http-7.2.15.tgz#a32bea9e67e99eef88150085aeebbe7aeecd39eb" - integrity sha512-TR7PEdmLWNIre3Zn8lvyb4lSrvPUJhKLystLnp4hBMcWsJqq5iK8S3bnlR4viZ9HMlf7bW7+Hm4SI6aB3tdUtw== +"@angular/platform-browser-dynamic@^8.2.14": + version "8.2.14" + resolved "https://registry.yarnpkg.com/@angular/platform-browser-dynamic/-/platform-browser-dynamic-8.2.14.tgz#4439a79fe10ec45170e6940a28835e9ff0918950" + integrity sha512-mO2JPR5kLU/A3AQngy9+R/Q5gaF9csMStBQjwsCRI0wNtlItOIGL6+wTYpiTuh/ux+WVN1F2sLcEYU4Zf1ud9A== dependencies: tslib "^1.9.0" -"@angular/platform-browser-dynamic@^7.2.15": - version "7.2.15" - resolved "https://registry.yarnpkg.com/@angular/platform-browser-dynamic/-/platform-browser-dynamic-7.2.15.tgz#e697159b565ef78bd7d276fa876d099172ad8735" - integrity sha512-UL2PqhzXMD769NQ6Lh6pxlBDKvN9Qol3XLRFil80lwJ1GRW16ITeYbCamcafIH2GOyd88IhmYcbMfUQ/6q4MMQ== +"@angular/platform-browser@^8.2.14": + version "8.2.14" + resolved "https://registry.yarnpkg.com/@angular/platform-browser/-/platform-browser-8.2.14.tgz#31f082e8ba977f9b89964d721c38cbc32ce0e433" + integrity sha512-MtJptptyKzsE37JZ2VB/tI4cvMrdAH+cT9pMBYZd66YSZfKjIj5s+AZo7z8ncoskQSB1o3HMfDjSK7QXGx1mLQ== dependencies: tslib "^1.9.0" -"@angular/platform-browser@^7.2.15": - version "7.2.15" - resolved "https://registry.yarnpkg.com/@angular/platform-browser/-/platform-browser-7.2.15.tgz#d6df74c427453e563c12bc2ec03a83bf10bb3805" - integrity sha512-aYgmPsbC9Tvp9vmKWD8voeAp4crwCay7/D6lM3ClEe2EeK934LuEXq3/uczMrFVbnIX7BBIo8fh03Tl7wbiGPw== +"@angular/platform-server@^8.2.14": + version "8.2.14" + resolved "https://registry.yarnpkg.com/@angular/platform-server/-/platform-server-8.2.14.tgz#393e42d82022ad072b652999696bd5fa0b5c6928" + integrity sha512-gGAgxMmac5CyLcwgB+qCD1o75An0NmpREh/lxPgz6n6Zs9JqdqpZROLSIHqGBaU6MWo1qiOfS6L08HwYPx7ipQ== dependencies: - tslib "^1.9.0" - -"@angular/platform-server@^7.2.15": - version "7.2.15" - resolved "https://registry.yarnpkg.com/@angular/platform-server/-/platform-server-7.2.15.tgz#06c8a4c1850da6289f643bd690fc7e1e8bdd6376" - integrity sha512-a7XhYlbmQ7pN6liFq8WqdX4GNoxCIXhlZqotZkfwJDsDy2E2yyvVx6BYCEOnSRvO9xXwfyBXiLfZ4Y2A7xeCoQ== - dependencies: - domino "^2.1.0" + domino "^2.1.2" tslib "^1.9.0" xhr2 "^0.1.4" -"@angular/router@^7.2.15": - version "7.2.15" - resolved "https://registry.yarnpkg.com/@angular/router/-/router-7.2.15.tgz#b2acbd07c17158801006cdd7e93113d6ec1f116e" - integrity sha512-qAubRJRQanguUqJQ76J9GSZ4JFtoyhJKRmX5P23ANZJXpB6YLzF2fJmOGi+E6cV8F0tKBMEq1pjxFTisx0MXwQ== +"@angular/router@^8.2.14": + version "8.2.14" + resolved "https://registry.yarnpkg.com/@angular/router/-/router-8.2.14.tgz#5f9f9707710983c2143aead79dcd2da520ae3eb8" + integrity sha512-DHA2BhODqV7F0g6ZKgFaZgbsqzHHWRcfWchCOrOVKu2rYiKUTwwHVLBgZAhrpNeinq2pWanVYSIhMr7wy+LfEA== dependencies: tslib "^1.9.0" @@ -247,6 +257,257 @@ dependencies: "@babel/highlight" "^7.0.0" +"@babel/code-frame@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.8.3.tgz#33e25903d7481181534e12ec0a25f16b6fcf419e" + integrity sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g== + dependencies: + "@babel/highlight" "^7.8.3" + +"@babel/compat-data@^7.8.0", "@babel/compat-data@^7.8.4": + version "7.8.5" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.8.5.tgz#d28ce872778c23551cbb9432fc68d28495b613b9" + integrity sha512-jWYUqQX/ObOhG1UiEkbH5SANsE/8oKXiQWjj7p7xgj9Zmnt//aUvyz4dBkK0HNsS8/cbyC5NmmH87VekW+mXFg== + dependencies: + browserslist "^4.8.5" + invariant "^2.2.4" + semver "^5.5.0" + +"@babel/core@7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.8.3.tgz#30b0ebb4dd1585de6923a0b4d179e0b9f5d82941" + integrity sha512-4XFkf8AwyrEG7Ziu3L2L0Cv+WyY47Tcsp70JFmpftbAA1K7YL/sgE9jh9HyNj08Y/U50ItUchpN0w6HxAoX1rA== + dependencies: + "@babel/code-frame" "^7.8.3" + "@babel/generator" "^7.8.3" + "@babel/helpers" "^7.8.3" + "@babel/parser" "^7.8.3" + "@babel/template" "^7.8.3" + "@babel/traverse" "^7.8.3" + "@babel/types" "^7.8.3" + convert-source-map "^1.7.0" + debug "^4.1.0" + gensync "^1.0.0-beta.1" + json5 "^2.1.0" + lodash "^4.17.13" + resolve "^1.3.2" + semver "^5.4.1" + source-map "^0.5.0" + +"@babel/core@^7.7.5": + version "7.8.4" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.8.4.tgz#d496799e5c12195b3602d0fddd77294e3e38e80e" + integrity sha512-0LiLrB2PwrVI+a2/IEskBopDYSd8BCb3rOvH7D5tzoWd696TBEduBvuLVm4Nx6rltrLZqvI3MCalB2K2aVzQjA== + dependencies: + "@babel/code-frame" "^7.8.3" + "@babel/generator" "^7.8.4" + "@babel/helpers" "^7.8.4" + "@babel/parser" "^7.8.4" + "@babel/template" "^7.8.3" + "@babel/traverse" "^7.8.4" + "@babel/types" "^7.8.3" + convert-source-map "^1.7.0" + debug "^4.1.0" + gensync "^1.0.0-beta.1" + json5 "^2.1.0" + lodash "^4.17.13" + resolve "^1.3.2" + semver "^5.4.1" + source-map "^0.5.0" + +"@babel/generator@^7.8.3", "@babel/generator@^7.8.4": + version "7.8.4" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.8.4.tgz#35bbc74486956fe4251829f9f6c48330e8d0985e" + integrity sha512-PwhclGdRpNAf3IxZb0YVuITPZmmrXz9zf6fH8lT4XbrmfQKr6ryBzhv593P5C6poJRciFCL/eHGW2NuGrgEyxA== + dependencies: + "@babel/types" "^7.8.3" + jsesc "^2.5.1" + lodash "^4.17.13" + source-map "^0.5.0" + +"@babel/helper-annotate-as-pure@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.8.3.tgz#60bc0bc657f63a0924ff9a4b4a0b24a13cf4deee" + integrity sha512-6o+mJrZBxOoEX77Ezv9zwW7WV8DdluouRKNY/IR5u/YTMuKHgugHOzYWlYvYLpLA9nPsQCAAASpCIbjI9Mv+Uw== + dependencies: + "@babel/types" "^7.8.3" + +"@babel/helper-builder-binary-assignment-operator-visitor@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.8.3.tgz#c84097a427a061ac56a1c30ebf54b7b22d241503" + integrity sha512-5eFOm2SyFPK4Rh3XMMRDjN7lBH0orh3ss0g3rTYZnBQ+r6YPj7lgDyCvPphynHvUrobJmeMignBr6Acw9mAPlw== + dependencies: + "@babel/helper-explode-assignable-expression" "^7.8.3" + "@babel/types" "^7.8.3" + +"@babel/helper-call-delegate@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-call-delegate/-/helper-call-delegate-7.8.3.tgz#de82619898aa605d409c42be6ffb8d7204579692" + integrity sha512-6Q05px0Eb+N4/GTyKPPvnkig7Lylw+QzihMpws9iiZQv7ZImf84ZsZpQH7QoWN4n4tm81SnSzPgHw2qtO0Zf3A== + dependencies: + "@babel/helper-hoist-variables" "^7.8.3" + "@babel/traverse" "^7.8.3" + "@babel/types" "^7.8.3" + +"@babel/helper-compilation-targets@^7.8.3": + version "7.8.4" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.8.4.tgz#03d7ecd454b7ebe19a254f76617e61770aed2c88" + integrity sha512-3k3BsKMvPp5bjxgMdrFyq0UaEO48HciVrOVF0+lon8pp95cyJ2ujAh0TrBHNMnJGT2rr0iKOJPFFbSqjDyf/Pg== + dependencies: + "@babel/compat-data" "^7.8.4" + browserslist "^4.8.5" + invariant "^2.2.4" + levenary "^1.1.1" + semver "^5.5.0" + +"@babel/helper-create-regexp-features-plugin@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.8.3.tgz#c774268c95ec07ee92476a3862b75cc2839beb79" + integrity sha512-Gcsm1OHCUr9o9TcJln57xhWHtdXbA2pgQ58S0Lxlks0WMGNXuki4+GLfX0p+L2ZkINUGZvfkz8rzoqJQSthI+Q== + dependencies: + "@babel/helper-regex" "^7.8.3" + regexpu-core "^4.6.0" + +"@babel/helper-define-map@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-define-map/-/helper-define-map-7.8.3.tgz#a0655cad5451c3760b726eba875f1cd8faa02c15" + integrity sha512-PoeBYtxoZGtct3md6xZOCWPcKuMuk3IHhgxsRRNtnNShebf4C8YonTSblsK4tvDbm+eJAw2HAPOfCr+Q/YRG/g== + dependencies: + "@babel/helper-function-name" "^7.8.3" + "@babel/types" "^7.8.3" + lodash "^4.17.13" + +"@babel/helper-explode-assignable-expression@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.8.3.tgz#a728dc5b4e89e30fc2dfc7d04fa28a930653f982" + integrity sha512-N+8eW86/Kj147bO9G2uclsg5pwfs/fqqY5rwgIL7eTBklgXjcOJ3btzS5iM6AitJcftnY7pm2lGsrJVYLGjzIw== + dependencies: + "@babel/traverse" "^7.8.3" + "@babel/types" "^7.8.3" + +"@babel/helper-function-name@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.8.3.tgz#eeeb665a01b1f11068e9fb86ad56a1cb1a824cca" + integrity sha512-BCxgX1BC2hD/oBlIFUgOCQDOPV8nSINxCwM3o93xP4P9Fq6aV5sgv2cOOITDMtCfQ+3PvHp3l689XZvAM9QyOA== + dependencies: + "@babel/helper-get-function-arity" "^7.8.3" + "@babel/template" "^7.8.3" + "@babel/types" "^7.8.3" + +"@babel/helper-get-function-arity@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz#b894b947bd004381ce63ea1db9f08547e920abd5" + integrity sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA== + dependencies: + "@babel/types" "^7.8.3" + +"@babel/helper-hoist-variables@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.8.3.tgz#1dbe9b6b55d78c9b4183fc8cdc6e30ceb83b7134" + integrity sha512-ky1JLOjcDUtSc+xkt0xhYff7Z6ILTAHKmZLHPxAhOP0Nd77O+3nCsd6uSVYur6nJnCI029CrNbYlc0LoPfAPQg== + dependencies: + "@babel/types" "^7.8.3" + +"@babel/helper-member-expression-to-functions@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.8.3.tgz#659b710498ea6c1d9907e0c73f206eee7dadc24c" + integrity sha512-fO4Egq88utkQFjbPrSHGmGLFqmrshs11d46WI+WZDESt7Wu7wN2G2Iu+NMMZJFDOVRHAMIkB5SNh30NtwCA7RA== + dependencies: + "@babel/types" "^7.8.3" + +"@babel/helper-module-imports@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.8.3.tgz#7fe39589b39c016331b6b8c3f441e8f0b1419498" + integrity sha512-R0Bx3jippsbAEtzkpZ/6FIiuzOURPcMjHp+Z6xPe6DtApDJx+w7UYyOLanZqO8+wKR9G10s/FmHXvxaMd9s6Kg== + dependencies: + "@babel/types" "^7.8.3" + +"@babel/helper-module-transforms@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.8.3.tgz#d305e35d02bee720fbc2c3c3623aa0c316c01590" + integrity sha512-C7NG6B7vfBa/pwCOshpMbOYUmrYQDfCpVL/JCRu0ek8B5p8kue1+BCXpg2vOYs7w5ACB9GTOBYQ5U6NwrMg+3Q== + dependencies: + "@babel/helper-module-imports" "^7.8.3" + "@babel/helper-simple-access" "^7.8.3" + "@babel/helper-split-export-declaration" "^7.8.3" + "@babel/template" "^7.8.3" + "@babel/types" "^7.8.3" + lodash "^4.17.13" + +"@babel/helper-optimise-call-expression@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.8.3.tgz#7ed071813d09c75298ef4f208956006b6111ecb9" + integrity sha512-Kag20n86cbO2AvHca6EJsvqAd82gc6VMGule4HwebwMlwkpXuVqrNRj6CkCV2sKxgi9MyAUnZVnZ6lJ1/vKhHQ== + dependencies: + "@babel/types" "^7.8.3" + +"@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz#9ea293be19babc0f52ff8ca88b34c3611b208670" + integrity sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ== + +"@babel/helper-regex@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-regex/-/helper-regex-7.8.3.tgz#139772607d51b93f23effe72105b319d2a4c6965" + integrity sha512-BWt0QtYv/cg/NecOAZMdcn/waj/5P26DR4mVLXfFtDokSR6fyuG0Pj+e2FqtSME+MqED1khnSMulkmGl8qWiUQ== + dependencies: + lodash "^4.17.13" + +"@babel/helper-remap-async-to-generator@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.8.3.tgz#273c600d8b9bf5006142c1e35887d555c12edd86" + integrity sha512-kgwDmw4fCg7AVgS4DukQR/roGp+jP+XluJE5hsRZwxCYGg+Rv9wSGErDWhlI90FODdYfd4xG4AQRiMDjjN0GzA== + dependencies: + "@babel/helper-annotate-as-pure" "^7.8.3" + "@babel/helper-wrap-function" "^7.8.3" + "@babel/template" "^7.8.3" + "@babel/traverse" "^7.8.3" + "@babel/types" "^7.8.3" + +"@babel/helper-replace-supers@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.8.3.tgz#91192d25f6abbcd41da8a989d4492574fb1530bc" + integrity sha512-xOUssL6ho41U81etpLoT2RTdvdus4VfHamCuAm4AHxGr+0it5fnwoVdwUJ7GFEqCsQYzJUhcbsN9wB9apcYKFA== + dependencies: + "@babel/helper-member-expression-to-functions" "^7.8.3" + "@babel/helper-optimise-call-expression" "^7.8.3" + "@babel/traverse" "^7.8.3" + "@babel/types" "^7.8.3" + +"@babel/helper-simple-access@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.8.3.tgz#7f8109928b4dab4654076986af575231deb639ae" + integrity sha512-VNGUDjx5cCWg4vvCTR8qQ7YJYZ+HBjxOgXEl7ounz+4Sn7+LMD3CFrCTEU6/qXKbA2nKg21CwhhBzO0RpRbdCw== + dependencies: + "@babel/template" "^7.8.3" + "@babel/types" "^7.8.3" + +"@babel/helper-split-export-declaration@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz#31a9f30070f91368a7182cf05f831781065fc7a9" + integrity sha512-3x3yOeyBhW851hroze7ElzdkeRXQYQbFIb7gLK1WQYsw2GWDay5gAJNw1sWJ0VFP6z5J1whqeXH/WCdCjZv6dA== + dependencies: + "@babel/types" "^7.8.3" + +"@babel/helper-wrap-function@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.8.3.tgz#9dbdb2bb55ef14aaa01fe8c99b629bd5352d8610" + integrity sha512-LACJrbUET9cQDzb6kG7EeD7+7doC3JNvUgTEQOx2qaO1fKlzE/Bf05qs9w1oXQMmXlPO65lC3Tq9S6gZpTErEQ== + dependencies: + "@babel/helper-function-name" "^7.8.3" + "@babel/template" "^7.8.3" + "@babel/traverse" "^7.8.3" + "@babel/types" "^7.8.3" + +"@babel/helpers@^7.8.3", "@babel/helpers@^7.8.4": + version "7.8.4" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.8.4.tgz#754eb3ee727c165e0a240d6c207de7c455f36f73" + integrity sha512-VPbe7wcQ4chu4TDQjimHv/5tj73qz88o12EPkO2ValS2QiQS/1F2SsjyIGNnAD0vF/nZS6Cf9i+vW6HIlnaR8w== + dependencies: + "@babel/template" "^7.8.3" + "@babel/traverse" "^7.8.4" + "@babel/types" "^7.8.3" + "@babel/highlight@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.0.0.tgz#f710c38c8d458e6dd9a201afb637fcb781ce99e4" @@ -256,6 +517,449 @@ esutils "^2.0.2" js-tokens "^4.0.0" +"@babel/highlight@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.8.3.tgz#28f173d04223eaaa59bc1d439a3836e6d1265797" + integrity sha512-PX4y5xQUvy0fnEVHrYOarRPXVWafSjTW9T0Hab8gVIawpl2Sj0ORyrygANq+KjcNlSSTw0YCLSNA8OyZ1I4yEg== + dependencies: + chalk "^2.0.0" + esutils "^2.0.2" + js-tokens "^4.0.0" + +"@babel/parser@^7.7.5", "@babel/parser@^7.8.3", "@babel/parser@^7.8.4": + version "7.8.4" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.8.4.tgz#d1dbe64691d60358a974295fa53da074dd2ce8e8" + integrity sha512-0fKu/QqildpXmPVaRBoXOlyBb3MC+J0A66x97qEfLOMkn3u6nfY5esWogQwi/K0BjASYy4DbnsEWnpNL6qT5Mw== + +"@babel/plugin-proposal-async-generator-functions@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.8.3.tgz#bad329c670b382589721b27540c7d288601c6e6f" + integrity sha512-NZ9zLv848JsV3hs8ryEh7Uaz/0KsmPLqv0+PdkDJL1cJy0K4kOCFa8zc1E3mp+RHPQcpdfb/6GovEsW4VDrOMw== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + "@babel/helper-remap-async-to-generator" "^7.8.3" + "@babel/plugin-syntax-async-generators" "^7.8.0" + +"@babel/plugin-proposal-dynamic-import@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.8.3.tgz#38c4fe555744826e97e2ae930b0fb4cc07e66054" + integrity sha512-NyaBbyLFXFLT9FP+zk0kYlUlA8XtCUbehs67F0nnEg7KICgMc2mNkIeu9TYhKzyXMkrapZFwAhXLdnt4IYHy1w== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + "@babel/plugin-syntax-dynamic-import" "^7.8.0" + +"@babel/plugin-proposal-json-strings@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.8.3.tgz#da5216b238a98b58a1e05d6852104b10f9a70d6b" + integrity sha512-KGhQNZ3TVCQG/MjRbAUwuH+14y9q0tpxs1nWWs3pbSleRdDro9SAMMDyye8HhY1gqZ7/NqIc8SKhya0wRDgP1Q== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + "@babel/plugin-syntax-json-strings" "^7.8.0" + +"@babel/plugin-proposal-nullish-coalescing-operator@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.8.3.tgz#e4572253fdeed65cddeecfdab3f928afeb2fd5d2" + integrity sha512-TS9MlfzXpXKt6YYomudb/KU7nQI6/xnapG6in1uZxoxDghuSMZsPb6D2fyUwNYSAp4l1iR7QtFOjkqcRYcUsfw== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0" + +"@babel/plugin-proposal-object-rest-spread@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.8.3.tgz#eb5ae366118ddca67bed583b53d7554cad9951bb" + integrity sha512-8qvuPwU/xxUCt78HocNlv0mXXo0wdh9VT1R04WU8HGOfaOob26pF+9P5/lYjN/q7DHOX1bvX60hnhOvuQUJdbA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + "@babel/plugin-syntax-object-rest-spread" "^7.8.0" + +"@babel/plugin-proposal-optional-catch-binding@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.8.3.tgz#9dee96ab1650eed88646ae9734ca167ac4a9c5c9" + integrity sha512-0gkX7J7E+AtAw9fcwlVQj8peP61qhdg/89D5swOkjYbkboA2CVckn3kiyum1DE0wskGb7KJJxBdyEBApDLLVdw== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + "@babel/plugin-syntax-optional-catch-binding" "^7.8.0" + +"@babel/plugin-proposal-optional-chaining@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.8.3.tgz#ae10b3214cb25f7adb1f3bc87ba42ca10b7e2543" + integrity sha512-QIoIR9abkVn+seDE3OjA08jWcs3eZ9+wJCKSRgo3WdEU2csFYgdScb+8qHB3+WXsGJD55u+5hWCISI7ejXS+kg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + "@babel/plugin-syntax-optional-chaining" "^7.8.0" + +"@babel/plugin-proposal-unicode-property-regex@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.8.3.tgz#b646c3adea5f98800c9ab45105ac34d06cd4a47f" + integrity sha512-1/1/rEZv2XGweRwwSkLpY+s60za9OZ1hJs4YDqFHCw0kYWYwL5IFljVY1MYBL+weT1l9pokDO2uhSTLVxzoHkQ== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.8.3" + "@babel/helper-plugin-utils" "^7.8.3" + +"@babel/plugin-syntax-async-generators@^7.8.0": + version "7.8.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" + integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-dynamic-import@^7.8.0": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz#62bf98b2da3cd21d626154fc96ee5b3cb68eacb3" + integrity sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-json-strings@^7.8.0": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz#01ca21b668cd8218c9e640cb6dd88c5412b2c96a" + integrity sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.0": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9" + integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-object-rest-spread@^7.8.0": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871" + integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-catch-binding@^7.8.0": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz#6111a265bcfb020eb9efd0fdfd7d26402b9ed6c1" + integrity sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-chaining@^7.8.0": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a" + integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-top-level-await@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.8.3.tgz#3acdece695e6b13aaf57fc291d1a800950c71391" + integrity sha512-kwj1j9lL/6Wd0hROD3b/OZZ7MSrZLqqn9RAZ5+cYYsflQ9HZBIKCUkr3+uL1MEJ1NePiUbf98jjiMQSv0NMR9g== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + +"@babel/plugin-transform-arrow-functions@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.8.3.tgz#82776c2ed0cd9e1a49956daeb896024c9473b8b6" + integrity sha512-0MRF+KC8EqH4dbuITCWwPSzsyO3HIWWlm30v8BbbpOrS1B++isGxPnnuq/IZvOX5J2D/p7DQalQm+/2PnlKGxg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + +"@babel/plugin-transform-async-to-generator@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.8.3.tgz#4308fad0d9409d71eafb9b1a6ee35f9d64b64086" + integrity sha512-imt9tFLD9ogt56Dd5CI/6XgpukMwd/fLGSrix2httihVe7LOGVPhyhMh1BU5kDM7iHD08i8uUtmV2sWaBFlHVQ== + dependencies: + "@babel/helper-module-imports" "^7.8.3" + "@babel/helper-plugin-utils" "^7.8.3" + "@babel/helper-remap-async-to-generator" "^7.8.3" + +"@babel/plugin-transform-block-scoped-functions@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.8.3.tgz#437eec5b799b5852072084b3ae5ef66e8349e8a3" + integrity sha512-vo4F2OewqjbB1+yaJ7k2EJFHlTP3jR634Z9Cj9itpqNjuLXvhlVxgnjsHsdRgASR8xYDrx6onw4vW5H6We0Jmg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + +"@babel/plugin-transform-block-scoping@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.8.3.tgz#97d35dab66857a437c166358b91d09050c868f3a" + integrity sha512-pGnYfm7RNRgYRi7bids5bHluENHqJhrV4bCZRwc5GamaWIIs07N4rZECcmJL6ZClwjDz1GbdMZFtPs27hTB06w== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + lodash "^4.17.13" + +"@babel/plugin-transform-classes@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.8.3.tgz#46fd7a9d2bb9ea89ce88720477979fe0d71b21b8" + integrity sha512-SjT0cwFJ+7Rbr1vQsvphAHwUHvSUPmMjMU/0P59G8U2HLFqSa082JO7zkbDNWs9kH/IUqpHI6xWNesGf8haF1w== + dependencies: + "@babel/helper-annotate-as-pure" "^7.8.3" + "@babel/helper-define-map" "^7.8.3" + "@babel/helper-function-name" "^7.8.3" + "@babel/helper-optimise-call-expression" "^7.8.3" + "@babel/helper-plugin-utils" "^7.8.3" + "@babel/helper-replace-supers" "^7.8.3" + "@babel/helper-split-export-declaration" "^7.8.3" + globals "^11.1.0" + +"@babel/plugin-transform-computed-properties@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.8.3.tgz#96d0d28b7f7ce4eb5b120bb2e0e943343c86f81b" + integrity sha512-O5hiIpSyOGdrQZRQ2ccwtTVkgUDBBiCuK//4RJ6UfePllUTCENOzKxfh6ulckXKc0DixTFLCfb2HVkNA7aDpzA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + +"@babel/plugin-transform-destructuring@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.8.3.tgz#20ddfbd9e4676906b1056ee60af88590cc7aaa0b" + integrity sha512-H4X646nCkiEcHZUZaRkhE2XVsoz0J/1x3VVujnn96pSoGCtKPA99ZZA+va+gK+92Zycd6OBKCD8tDb/731bhgQ== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + +"@babel/plugin-transform-dotall-regex@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.8.3.tgz#c3c6ec5ee6125c6993c5cbca20dc8621a9ea7a6e" + integrity sha512-kLs1j9Nn4MQoBYdRXH6AeaXMbEJFaFu/v1nQkvib6QzTj8MZI5OQzqmD83/2jEM1z0DLilra5aWO5YpyC0ALIw== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.8.3" + "@babel/helper-plugin-utils" "^7.8.3" + +"@babel/plugin-transform-duplicate-keys@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.8.3.tgz#8d12df309aa537f272899c565ea1768e286e21f1" + integrity sha512-s8dHiBUbcbSgipS4SMFuWGqCvyge5V2ZeAWzR6INTVC3Ltjig/Vw1G2Gztv0vU/hRG9X8IvKvYdoksnUfgXOEQ== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + +"@babel/plugin-transform-exponentiation-operator@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.8.3.tgz#581a6d7f56970e06bf51560cd64f5e947b70d7b7" + integrity sha512-zwIpuIymb3ACcInbksHaNcR12S++0MDLKkiqXHl3AzpgdKlFNhog+z/K0+TGW+b0w5pgTq4H6IwV/WhxbGYSjQ== + dependencies: + "@babel/helper-builder-binary-assignment-operator-visitor" "^7.8.3" + "@babel/helper-plugin-utils" "^7.8.3" + +"@babel/plugin-transform-for-of@^7.8.3": + version "7.8.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.8.4.tgz#6fe8eae5d6875086ee185dd0b098a8513783b47d" + integrity sha512-iAXNlOWvcYUYoV8YIxwS7TxGRJcxyl8eQCfT+A5j8sKUzRFvJdcyjp97jL2IghWSRDaL2PU2O2tX8Cu9dTBq5A== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + +"@babel/plugin-transform-function-name@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.8.3.tgz#279373cb27322aaad67c2683e776dfc47196ed8b" + integrity sha512-rO/OnDS78Eifbjn5Py9v8y0aR+aSYhDhqAwVfsTl0ERuMZyr05L1aFSCJnbv2mmsLkit/4ReeQ9N2BgLnOcPCQ== + dependencies: + "@babel/helper-function-name" "^7.8.3" + "@babel/helper-plugin-utils" "^7.8.3" + +"@babel/plugin-transform-literals@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.8.3.tgz#aef239823d91994ec7b68e55193525d76dbd5dc1" + integrity sha512-3Tqf8JJ/qB7TeldGl+TT55+uQei9JfYaregDcEAyBZ7akutriFrt6C/wLYIer6OYhleVQvH/ntEhjE/xMmy10A== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + +"@babel/plugin-transform-member-expression-literals@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.8.3.tgz#963fed4b620ac7cbf6029c755424029fa3a40410" + integrity sha512-3Wk2EXhnw+rP+IDkK6BdtPKsUE5IeZ6QOGrPYvw52NwBStw9V1ZVzxgK6fSKSxqUvH9eQPR3tm3cOq79HlsKYA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + +"@babel/plugin-transform-modules-amd@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.8.3.tgz#65606d44616b50225e76f5578f33c568a0b876a5" + integrity sha512-MadJiU3rLKclzT5kBH4yxdry96odTUwuqrZM+GllFI/VhxfPz+k9MshJM+MwhfkCdxxclSbSBbUGciBngR+kEQ== + dependencies: + "@babel/helper-module-transforms" "^7.8.3" + "@babel/helper-plugin-utils" "^7.8.3" + babel-plugin-dynamic-import-node "^2.3.0" + +"@babel/plugin-transform-modules-commonjs@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.8.3.tgz#df251706ec331bd058a34bdd72613915f82928a5" + integrity sha512-JpdMEfA15HZ/1gNuB9XEDlZM1h/gF/YOH7zaZzQu2xCFRfwc01NXBMHHSTT6hRjlXJJs5x/bfODM3LiCk94Sxg== + dependencies: + "@babel/helper-module-transforms" "^7.8.3" + "@babel/helper-plugin-utils" "^7.8.3" + "@babel/helper-simple-access" "^7.8.3" + babel-plugin-dynamic-import-node "^2.3.0" + +"@babel/plugin-transform-modules-systemjs@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.8.3.tgz#d8bbf222c1dbe3661f440f2f00c16e9bb7d0d420" + integrity sha512-8cESMCJjmArMYqa9AO5YuMEkE4ds28tMpZcGZB/jl3n0ZzlsxOAi3mC+SKypTfT8gjMupCnd3YiXCkMjj2jfOg== + dependencies: + "@babel/helper-hoist-variables" "^7.8.3" + "@babel/helper-module-transforms" "^7.8.3" + "@babel/helper-plugin-utils" "^7.8.3" + babel-plugin-dynamic-import-node "^2.3.0" + +"@babel/plugin-transform-modules-umd@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.8.3.tgz#592d578ce06c52f5b98b02f913d653ffe972661a" + integrity sha512-evhTyWhbwbI3/U6dZAnx/ePoV7H6OUG+OjiJFHmhr9FPn0VShjwC2kdxqIuQ/+1P50TMrneGzMeyMTFOjKSnAw== + dependencies: + "@babel/helper-module-transforms" "^7.8.3" + "@babel/helper-plugin-utils" "^7.8.3" + +"@babel/plugin-transform-named-capturing-groups-regex@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.8.3.tgz#a2a72bffa202ac0e2d0506afd0939c5ecbc48c6c" + integrity sha512-f+tF/8UVPU86TrCb06JoPWIdDpTNSGGcAtaD9mLP0aYGA0OS0j7j7DHJR0GTFrUZPUU6loZhbsVZgTh0N+Qdnw== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.8.3" + +"@babel/plugin-transform-new-target@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.8.3.tgz#60cc2ae66d85c95ab540eb34babb6434d4c70c43" + integrity sha512-QuSGysibQpyxexRyui2vca+Cmbljo8bcRckgzYV4kRIsHpVeyeC3JDO63pY+xFZ6bWOBn7pfKZTqV4o/ix9sFw== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + +"@babel/plugin-transform-object-super@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.8.3.tgz#ebb6a1e7a86ffa96858bd6ac0102d65944261725" + integrity sha512-57FXk+gItG/GejofIyLIgBKTas4+pEU47IXKDBWFTxdPd7F80H8zybyAY7UoblVfBhBGs2EKM+bJUu2+iUYPDQ== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + "@babel/helper-replace-supers" "^7.8.3" + +"@babel/plugin-transform-parameters@^7.8.3": + version "7.8.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.8.4.tgz#1d5155de0b65db0ccf9971165745d3bb990d77d3" + integrity sha512-IsS3oTxeTsZlE5KqzTbcC2sV0P9pXdec53SU+Yxv7o/6dvGM5AkTotQKhoSffhNgZ/dftsSiOoxy7evCYJXzVA== + dependencies: + "@babel/helper-call-delegate" "^7.8.3" + "@babel/helper-get-function-arity" "^7.8.3" + "@babel/helper-plugin-utils" "^7.8.3" + +"@babel/plugin-transform-property-literals@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.8.3.tgz#33194300d8539c1ed28c62ad5087ba3807b98263" + integrity sha512-uGiiXAZMqEoQhRWMK17VospMZh5sXWg+dlh2soffpkAl96KAm+WZuJfa6lcELotSRmooLqg0MWdH6UUq85nmmg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + +"@babel/plugin-transform-regenerator@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.8.3.tgz#b31031e8059c07495bf23614c97f3d9698bc6ec8" + integrity sha512-qt/kcur/FxrQrzFR432FGZznkVAjiyFtCOANjkAKwCbt465L6ZCiUQh2oMYGU3Wo8LRFJxNDFwWn106S5wVUNA== + dependencies: + regenerator-transform "^0.14.0" + +"@babel/plugin-transform-reserved-words@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.8.3.tgz#9a0635ac4e665d29b162837dd3cc50745dfdf1f5" + integrity sha512-mwMxcycN3omKFDjDQUl+8zyMsBfjRFr0Zn/64I41pmjv4NJuqcYlEtezwYtw9TFd9WR1vN5kiM+O0gMZzO6L0A== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + +"@babel/plugin-transform-shorthand-properties@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.8.3.tgz#28545216e023a832d4d3a1185ed492bcfeac08c8" + integrity sha512-I9DI6Odg0JJwxCHzbzW08ggMdCezoWcuQRz3ptdudgwaHxTjxw5HgdFJmZIkIMlRymL6YiZcped4TTCB0JcC8w== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + +"@babel/plugin-transform-spread@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.8.3.tgz#9c8ffe8170fdfb88b114ecb920b82fb6e95fe5e8" + integrity sha512-CkuTU9mbmAoFOI1tklFWYYbzX5qCIZVXPVy0jpXgGwkplCndQAa58s2jr66fTeQnA64bDox0HL4U56CFYoyC7g== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + +"@babel/plugin-transform-sticky-regex@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.8.3.tgz#be7a1290f81dae767475452199e1f76d6175b100" + integrity sha512-9Spq0vGCD5Bb4Z/ZXXSK5wbbLFMG085qd2vhL1JYu1WcQ5bXqZBAYRzU1d+p79GcHs2szYv5pVQCX13QgldaWw== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + "@babel/helper-regex" "^7.8.3" + +"@babel/plugin-transform-template-literals@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.8.3.tgz#7bfa4732b455ea6a43130adc0ba767ec0e402a80" + integrity sha512-820QBtykIQOLFT8NZOcTRJ1UNuztIELe4p9DCgvj4NK+PwluSJ49we7s9FB1HIGNIYT7wFUJ0ar2QpCDj0escQ== + dependencies: + "@babel/helper-annotate-as-pure" "^7.8.3" + "@babel/helper-plugin-utils" "^7.8.3" + +"@babel/plugin-transform-typeof-symbol@^7.8.3": + version "7.8.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.8.4.tgz#ede4062315ce0aaf8a657a920858f1a2f35fc412" + integrity sha512-2QKyfjGdvuNfHsb7qnBBlKclbD4CfshH2KvDabiijLMGXPHJXGxtDzwIF7bQP+T0ysw8fYTtxPafgfs/c1Lrqg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.3" + +"@babel/plugin-transform-unicode-regex@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.8.3.tgz#0cef36e3ba73e5c57273effb182f46b91a1ecaad" + integrity sha512-+ufgJjYdmWfSQ+6NS9VGUR2ns8cjJjYbrbi11mZBTaWm+Fui/ncTLFF28Ei1okavY+xkojGr1eJxNsWYeA5aZw== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.8.3" + "@babel/helper-plugin-utils" "^7.8.3" + +"@babel/preset-env@7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.8.3.tgz#dc0fb2938f52bbddd79b3c861a4b3427dd3a6c54" + integrity sha512-Rs4RPL2KjSLSE2mWAx5/iCH+GC1ikKdxPrhnRS6PfFVaiZeom22VFKN4X8ZthyN61kAaR05tfXTbCvatl9WIQg== + dependencies: + "@babel/compat-data" "^7.8.0" + "@babel/helper-compilation-targets" "^7.8.3" + "@babel/helper-module-imports" "^7.8.3" + "@babel/helper-plugin-utils" "^7.8.3" + "@babel/plugin-proposal-async-generator-functions" "^7.8.3" + "@babel/plugin-proposal-dynamic-import" "^7.8.3" + "@babel/plugin-proposal-json-strings" "^7.8.3" + "@babel/plugin-proposal-nullish-coalescing-operator" "^7.8.3" + "@babel/plugin-proposal-object-rest-spread" "^7.8.3" + "@babel/plugin-proposal-optional-catch-binding" "^7.8.3" + "@babel/plugin-proposal-optional-chaining" "^7.8.3" + "@babel/plugin-proposal-unicode-property-regex" "^7.8.3" + "@babel/plugin-syntax-async-generators" "^7.8.0" + "@babel/plugin-syntax-dynamic-import" "^7.8.0" + "@babel/plugin-syntax-json-strings" "^7.8.0" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0" + "@babel/plugin-syntax-object-rest-spread" "^7.8.0" + "@babel/plugin-syntax-optional-catch-binding" "^7.8.0" + "@babel/plugin-syntax-optional-chaining" "^7.8.0" + "@babel/plugin-syntax-top-level-await" "^7.8.3" + "@babel/plugin-transform-arrow-functions" "^7.8.3" + "@babel/plugin-transform-async-to-generator" "^7.8.3" + "@babel/plugin-transform-block-scoped-functions" "^7.8.3" + "@babel/plugin-transform-block-scoping" "^7.8.3" + "@babel/plugin-transform-classes" "^7.8.3" + "@babel/plugin-transform-computed-properties" "^7.8.3" + "@babel/plugin-transform-destructuring" "^7.8.3" + "@babel/plugin-transform-dotall-regex" "^7.8.3" + "@babel/plugin-transform-duplicate-keys" "^7.8.3" + "@babel/plugin-transform-exponentiation-operator" "^7.8.3" + "@babel/plugin-transform-for-of" "^7.8.3" + "@babel/plugin-transform-function-name" "^7.8.3" + "@babel/plugin-transform-literals" "^7.8.3" + "@babel/plugin-transform-member-expression-literals" "^7.8.3" + "@babel/plugin-transform-modules-amd" "^7.8.3" + "@babel/plugin-transform-modules-commonjs" "^7.8.3" + "@babel/plugin-transform-modules-systemjs" "^7.8.3" + "@babel/plugin-transform-modules-umd" "^7.8.3" + "@babel/plugin-transform-named-capturing-groups-regex" "^7.8.3" + "@babel/plugin-transform-new-target" "^7.8.3" + "@babel/plugin-transform-object-super" "^7.8.3" + "@babel/plugin-transform-parameters" "^7.8.3" + "@babel/plugin-transform-property-literals" "^7.8.3" + "@babel/plugin-transform-regenerator" "^7.8.3" + "@babel/plugin-transform-reserved-words" "^7.8.3" + "@babel/plugin-transform-shorthand-properties" "^7.8.3" + "@babel/plugin-transform-spread" "^7.8.3" + "@babel/plugin-transform-sticky-regex" "^7.8.3" + "@babel/plugin-transform-template-literals" "^7.8.3" + "@babel/plugin-transform-typeof-symbol" "^7.8.3" + "@babel/plugin-transform-unicode-regex" "^7.8.3" + "@babel/types" "^7.8.3" + browserslist "^4.8.2" + core-js-compat "^3.6.2" + invariant "^2.2.2" + levenary "^1.1.0" + semver "^5.5.0" + "@babel/runtime-corejs3@^7.7.4": version "7.7.6" resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.7.6.tgz#5b1044ea11b659d288f77190e19c62da959ed9a3" @@ -271,11 +975,49 @@ dependencies: regenerator-runtime "^0.13.2" +"@babel/template@^7.7.4", "@babel/template@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.3.tgz#e02ad04fe262a657809327f578056ca15fd4d1b8" + integrity sha512-04m87AcQgAFdvuoyiQ2kgELr2tV8B4fP/xJAVUL3Yb3bkNdMedD3d0rlSQr3PegP0cms3eHjl1F7PWlvWbU8FQ== + dependencies: + "@babel/code-frame" "^7.8.3" + "@babel/parser" "^7.8.3" + "@babel/types" "^7.8.3" + +"@babel/traverse@^7.7.4", "@babel/traverse@^7.8.3", "@babel/traverse@^7.8.4": + version "7.8.4" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.8.4.tgz#f0845822365f9d5b0e312ed3959d3f827f869e3c" + integrity sha512-NGLJPZwnVEyBPLI+bl9y9aSnxMhsKz42so7ApAv9D+b4vAFPpY013FTS9LdKxcABoIYFU52HcYga1pPlx454mg== + dependencies: + "@babel/code-frame" "^7.8.3" + "@babel/generator" "^7.8.4" + "@babel/helper-function-name" "^7.8.3" + "@babel/helper-split-export-declaration" "^7.8.3" + "@babel/parser" "^7.8.4" + "@babel/types" "^7.8.3" + debug "^4.1.0" + globals "^11.1.0" + lodash "^4.17.13" + +"@babel/types@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.8.3.tgz#5a383dffa5416db1b73dedffd311ffd0788fb31c" + integrity sha512-jBD+G8+LWpMBBWvVcdr4QysjUE4mU/syrhN17o1u3gx0/WzJB1kwiVZAXRtWbsIPOwW8pF/YJV5+nmetPzepXg== + dependencies: + esutils "^2.0.2" + lodash "^4.17.13" + to-fast-properties "^2.0.0" + "@fortawesome/fontawesome-free@^5.5.0": version "5.5.0" resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.5.0.tgz#0c6c53823d04457ae669cd19567b8a21dbb4fcfd" integrity sha512-p4lu0jfj5QN013ddArh99r3OXZ/fp9rbovs62LfaO70OMBsAXxtNd0lAq/97fitrscR0fqfd+/a5KNcp6Sh/0A== +"@istanbuljs/schema@^0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.2.tgz#26520bf09abe4a5644cd5414e37125a8954241dd" + integrity sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw== + "@mrmlnc/readdir-enhanced@^2.2.1": version "2.2.1" resolved "https://registry.yarnpkg.com/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz#524af240d1a360527b730475ecfa1344aa540dde" @@ -284,72 +1026,72 @@ call-me-maybe "^1.0.1" glob-to-regexp "^0.3.0" -"@ng-bootstrap/ng-bootstrap@^4.1.0": - version "4.2.2" - resolved "https://registry.yarnpkg.com/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-4.2.2.tgz#a1c3a9576656cb4f793bbc3df56dfbdeb098f2fb" - integrity sha512-v8QmC17bv9he5Ep6zutaI9aQ2w/2NqySP0fejOKe7cacKpGUqsLIakpyd2FD7mfZu7pSCCtHYpRWR+h6yq+Ngg== +"@ng-bootstrap/ng-bootstrap@^5.2.1": + version "5.2.1" + resolved "https://registry.yarnpkg.com/@ng-bootstrap/ng-bootstrap/-/ng-bootstrap-5.2.1.tgz#4fea4b561a8fa2422d31d492ffa3843b22669cfd" + integrity sha512-73/FX3wkDCQgdTBIa/pAOUB+DQLbag2vET3NIaqNz8Zno6cilkefY1zdlQ2zbwONcGzCyoTPFAUPivHgvoy9/w== dependencies: tslib "^1.9.0" -"@ng-dynamic-forms/core@^7.1.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@ng-dynamic-forms/core/-/core-7.2.0.tgz#788253da5f0bc44ea69cd1eb0071b78f091ea389" - integrity sha512-eRb26jDNIXiBla0hzgEpdL1Za9e6RD9phbV9PxzIkNlYKY2+FQowQBPmGBuck21E9okfVkEle2RJ4lUmozV5jw== +"@ng-dynamic-forms/core@8.1.1": + version "8.1.1" + resolved "https://registry.yarnpkg.com/@ng-dynamic-forms/core/-/core-8.1.1.tgz#41af2d95f6b0e426030aa993be5d4937c81a8e10" + integrity sha512-x0wUAv2r929CqTbRnAutAWmrSKd0pbbk4hwqguMl3D4Xk2/Qc565LYWWFRnq+Xr9NgyCONXKBu9unxspfG4g0A== dependencies: tslib "^1.9.0" -"@ng-dynamic-forms/ui-ng-bootstrap@^7.1.0": - version "7.2.0" - resolved "https://registry.yarnpkg.com/@ng-dynamic-forms/ui-ng-bootstrap/-/ui-ng-bootstrap-7.2.0.tgz#b01da1f0a149108218eb0a1e6f26700a6a5bdbb9" - integrity sha512-iX6/7p5FK7yCEDxbKRs//JwsklQGx//0LyB4FkzPil7LNjXqJCabA7WS3+lUFZQdYXM0fAcyve+UZJCM7yTZiA== +"@ng-dynamic-forms/ui-ng-bootstrap@8.1.1": + version "8.1.1" + resolved "https://registry.yarnpkg.com/@ng-dynamic-forms/ui-ng-bootstrap/-/ui-ng-bootstrap-8.1.1.tgz#a2444ec68c3416533b625b74ce861ea06dcaa74b" + integrity sha512-LJEbJatev6Lg+Fs6/+xxXJ2DydUO/5sEC2hwTET8BRZmeThL7//4cLbOTKihY2X/JL16cEsIFB8mxgfBeJ2c3Q== dependencies: tslib "^1.9.0" -"@ngrx/effects@^7.3.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@ngrx/effects/-/effects-7.4.0.tgz#7d1977538cf85e42ab48fd648acdfbc8b52f93d3" - integrity sha512-YjgB17WnLCBDPjAkHduKWsLFSGLZryPaTjY3EIvMF+WTRPDlgC5SAv2n7p3YIei6g6IYcEvOwLWBqZHFUXTgBw== +"@ngrx/effects@^8.6.0": + version "8.6.0" + resolved "https://registry.yarnpkg.com/@ngrx/effects/-/effects-8.6.0.tgz#a0d7339597a5128c5cf896ddcf93f73406a45860" + integrity sha512-JdyJLQbv/wnE0ZPY9DcDOtF9PzJuzsKWmIWgIGunHF18wdjk5O8Zpkcrxq18wDRL6geg5UTtNJRMvTQhpDbzow== -"@ngrx/entity@^7.3.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@ngrx/entity/-/entity-7.4.0.tgz#634cdff1db9629ca0e64c1d6b1e43dc15f4e2ca6" - integrity sha512-aFRDTNp6IFkYFlP9gV6hgNgtDYot9KYF8WVbaQTao9ihmdPumMBOCeRttPPiHS/cU41w9nW3xF53NgxQPnEiQA== +"@ngrx/entity@^8.6.0": + version "8.6.0" + resolved "https://registry.yarnpkg.com/@ngrx/entity/-/entity-8.6.0.tgz#63e7875d0e83e552249b8b75bb9b6aba248cae2a" + integrity sha512-Qq+ANgsHd2/i7gam1j05hxA8kPWQyf5ewtCLlbtMJI/qLmvA6ruSE8PYNNwrt90wm4BWdks2zKE5ERzvPzto0w== -"@ngrx/router-store@^7.3.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@ngrx/router-store/-/router-store-7.4.0.tgz#69c085bda3022117169f87ed5753b951de7d376d" - integrity sha512-ZpwTO1/ha3pxO7NV3jIfnwipBN1A719IjAOgrcmI8Ut06VH3HY/7JVFTkwLN/FyuHvl4EOlAVYmMAblmrymUWA== +"@ngrx/router-store@^8.6.0": + version "8.6.0" + resolved "https://registry.yarnpkg.com/@ngrx/router-store/-/router-store-8.6.0.tgz#3bda275722e476e8604fd57af81f37687662d673" + integrity sha512-4Dvl6dfOj15lNZ63wucRNcTEHUi0hEqapOBVRslfAsnaSRo2t1lOvfX7b68IbxPiqzabTBdIeEkJwAC2q/rZZg== -"@ngrx/schematics@^7.3.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@ngrx/schematics/-/schematics-7.4.0.tgz#c430e11e60b4ef9cd60d92569da65b29847bce89" - integrity sha512-H0endOV7nYWDaFH7mOJAWFGVymlOYynwjEHZPWeLQFZIFUaekKOrgrHpc+rygu1Hov5ww8lNXHgAunm4vpvwMA== +"@ngrx/schematics@^8.6.0": + version "8.6.0" + resolved "https://registry.yarnpkg.com/@ngrx/schematics/-/schematics-8.6.0.tgz#5387c0abc438768e4cb56c0b617082e0db2f00d7" + integrity sha512-d28FVsLWFJYxpMFnqzWvdbFSSPNlLUIezd0c4zlyf4CyNS/C8aw6Lio9xjOJHhgZuHvFuO9GwRrYV/GaS6wC7A== -"@ngrx/store-devtools@^7.3.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@ngrx/store-devtools/-/store-devtools-7.4.0.tgz#5a73469c70322351c4224f4529c5123f587a5997" - integrity sha512-ZmPpquprBYUozbLuLMLZzUhI+LnMNGMNg8x1ij9yDxXWQADcJm1Zu7kouYE1r5SoCYxKfwJ3Ia1VQfS3A5S8dw== +"@ngrx/store-devtools@^8.6.0": + version "8.6.0" + resolved "https://registry.yarnpkg.com/@ngrx/store-devtools/-/store-devtools-8.6.0.tgz#ac287e4b094d099781cdc9f3281039c0e988296c" + integrity sha512-PWZmiOZE0J56GFfZpuzKLb7w0K2c6OXZSp/eWDeAvtdHFD4/Nas1i4TXtiWWMWWnSZeNs0hNIg4nFJXi2EddJQ== -"@ngrx/store@^7.3.0": - version "7.4.0" - resolved "https://registry.yarnpkg.com/@ngrx/store/-/store-7.4.0.tgz#525a343aa45d7f6ca60f3301a23a27669c14bbce" - integrity sha512-kwTUHgfgBeAL4RQBjZO46z9v4Xzg8PXAgY4WwXdt3zUk1tF4ZvijMleFvFRUoiJJfxF/UM6jgIZ/yGrX2dXQuA== +"@ngrx/store@^8.6.0": + version "8.6.0" + resolved "https://registry.yarnpkg.com/@ngrx/store/-/store-8.6.0.tgz#8540c5bd40b33fc2f443e7e86f47c0d801b8f413" + integrity sha512-K4cvCEa+5hw9qrETQWO+Cha3YbVCAT8yaIKJr/N35KntTL9mQMjoL+51JWLZfBwPV0e19CFgJIyrBnVUTxwr2A== -"@ngtools/webpack@7.3.9", "@ngtools/webpack@^7.3.9": - version "7.3.9" - resolved "https://registry.yarnpkg.com/@ngtools/webpack/-/webpack-7.3.9.tgz#db115dba8cc0886d8d822723be4119d3849fb4e3" - integrity sha512-+ROpqfCXLdQwfP+UNDLk4p959ZrocpStkdd2Iy9CeOJ8yDkityqpstTwQC3oHzzu/95BiyZ0hrHbM6AsPPIvJg== +"@ngtools/webpack@8.3.25", "@ngtools/webpack@^8.3.25": + version "8.3.25" + resolved "https://registry.yarnpkg.com/@ngtools/webpack/-/webpack-8.3.25.tgz#f33dfb114463662b16b719031fd99ebf21354cf1" + integrity sha512-yHvgxXUXlgdWijtzcRjTaUqzK+6TVK/8p7PreBR00GsLxhl4U1jQSC6yDaZUCjOaEkiczFWl4hEuC4wTU/hLdg== dependencies: - "@angular-devkit/core" "7.3.9" + "@angular-devkit/core" "8.3.25" enhanced-resolve "4.1.0" - rxjs "6.3.3" - tree-kill "1.2.1" - webpack-sources "1.3.0" + rxjs "6.4.0" + tree-kill "1.2.2" + webpack-sources "1.4.3" -"@nguniversal/express-engine@^7.1.1": - version "7.1.1" - resolved "https://registry.yarnpkg.com/@nguniversal/express-engine/-/express-engine-7.1.1.tgz#9445ccb374dabdadccc8de98ca8a79b536ae14de" - integrity sha512-RJ2VATA6s48bYNrAfjnkjUCohpR7ehiOySwGA2vuUIWCWXKDIIPxgmET5ffVHy1a2XdMsOgrQ9Whth7+CxnUgw== +"@nguniversal/express-engine@^8.2.6": + version "8.2.6" + resolved "https://registry.yarnpkg.com/@nguniversal/express-engine/-/express-engine-8.2.6.tgz#3930551727b5be1256f0aefa1ffd3c37cb420f9f" + integrity sha512-IKUKTpesgjYyB0Xg+fFhSbwbGBJhG0Wfn8MkQAi9RgSi8QsrSMkI3oUXc86Z7fpQL55D/ZIH7PekoC0Fmh/kxA== "@ngx-translate/core@11.0.1": version "11.0.1" @@ -365,10 +1107,10 @@ dependencies: tslib "^1.9.0" -"@nicky-lenaers/ngx-scroll-to@^1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@nicky-lenaers/ngx-scroll-to/-/ngx-scroll-to-1.0.0.tgz#2afdc03e5b3218bbb5e19ec69fb1e7f7c8eb83dc" - integrity sha512-IBKmt9D8gkntSwwQyAWCtOY552JLWcwTDlKOduEF6h8SYfpc1eSJu65rz8zHpur2MRZeLCGaIV+woBLpGYk8YQ== +"@nicky-lenaers/ngx-scroll-to@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@nicky-lenaers/ngx-scroll-to/-/ngx-scroll-to-3.0.1.tgz#e690e2ce7c6195373ad223cee411daaab3831b12" + integrity sha512-n7kwFUfV7B2UyRDQPegziXPp9zmRdEZiIgk2jJSirLrZf2jW96r25DNOvoahjQnK4PS3at+JD9LIWF+WyI0Lhg== dependencies: tslib "^1.9.0" @@ -377,14 +1119,13 @@ resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.1.tgz#53f349bb986ab273d601175aa1b25a655ab90ee3" integrity sha512-KU/VDjC5RwtDUZiz3d+DHXJF2lp5hB9dn552TXIyptj8SH1vXmR40mG0JgGq03IlYsOgGfcv8xrLpSQ0YUMQdA== -"@schematics/angular@7.3.9": - version "7.3.9" - resolved "https://registry.yarnpkg.com/@schematics/angular/-/angular-7.3.9.tgz#f57baf1cd9588d4f1035974d06fd8f3d54df021a" - integrity sha512-B3lytFtFeYNLfWdlrIzvy3ulFRccD2/zkoL0734J+DAGfUz7vbysJ50RwYL46sQUcKdZdvb48ktfu1S8yooP6Q== +"@schematics/angular@8.3.25": + version "8.3.25" + resolved "https://registry.yarnpkg.com/@schematics/angular/-/angular-8.3.25.tgz#11252399e30e2ddb94323e5e438bb69839fb9464" + integrity sha512-/vEPtE+fvgsWPml/MVqzmlGPBujadPPNwaTuuj5Uz1aVcKeEYzLkbN8YQOpml4vxZHCF8RDwNdGiU4SZg63Jfg== dependencies: - "@angular-devkit/core" "7.3.9" - "@angular-devkit/schematics" "7.3.9" - typescript "3.2.4" + "@angular-devkit/core" "8.3.25" + "@angular-devkit/schematics" "8.3.25" "@schematics/angular@^0.7.5": version "0.7.5" @@ -395,18 +1136,18 @@ "@angular-devkit/schematics" "0.7.5" typescript ">=2.6.2 <2.10" -"@schematics/update@0.13.9": - version "0.13.9" - resolved "https://registry.yarnpkg.com/@schematics/update/-/update-0.13.9.tgz#60d338676d10d24d1b12812a0624f6e7c3dbcd06" - integrity sha512-4MQcaKFxhMzZyE//+DknDh3h3duy3avg2oxSHxdwXlCZ8Q92+4lpegjJcSRiqlEwO4qeJ5XnrjrvzfIiaIZOmA== +"@schematics/update@0.803.25": + version "0.803.25" + resolved "https://registry.yarnpkg.com/@schematics/update/-/update-0.803.25.tgz#d424dfb4eaa06215ea447993613da2730327097b" + integrity sha512-VIlqhJsCStA3aO4llxZ7lAOvQUqppyZdrEO7f/ApIJmuofPQTkO5Hx21tnv0dyExwoqPCSIHzEu4Tmc0/TWM1A== dependencies: - "@angular-devkit/core" "7.3.9" - "@angular-devkit/schematics" "7.3.9" + "@angular-devkit/core" "8.3.25" + "@angular-devkit/schematics" "8.3.25" "@yarnpkg/lockfile" "1.1.0" ini "1.3.5" - pacote "9.4.0" - rxjs "6.3.3" - semver "5.6.0" + pacote "9.5.5" + rxjs "6.4.0" + semver "6.3.0" semver-intersect "1.4.0" "@types/acorn@^4.0.3": @@ -701,15 +1442,6 @@ semver "^6.3.0" tsutils "^3.17.1" -"@webassemblyjs/ast@1.7.11": - version "1.7.11" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.7.11.tgz#b988582cafbb2b095e8b556526f30c90d057cace" - integrity sha512-ZEzy4vjvTzScC+SH8RBssQUawpaInUdMTYwYYLh54/s8TuT0gBLuyUnppKsVyZEi876VmmStKsUs28UxPgdvrA== - dependencies: - "@webassemblyjs/helper-module-context" "1.7.11" - "@webassemblyjs/helper-wasm-bytecode" "1.7.11" - "@webassemblyjs/wast-parser" "1.7.11" - "@webassemblyjs/ast@1.8.5": version "1.8.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.8.5.tgz#51b1c5fe6576a34953bf4b253df9f0d490d9e359" @@ -719,43 +1451,21 @@ "@webassemblyjs/helper-wasm-bytecode" "1.8.5" "@webassemblyjs/wast-parser" "1.8.5" -"@webassemblyjs/floating-point-hex-parser@1.7.11": - version "1.7.11" - resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.7.11.tgz#a69f0af6502eb9a3c045555b1a6129d3d3f2e313" - integrity sha512-zY8dSNyYcgzNRNT666/zOoAyImshm3ycKdoLsyDw/Bwo6+/uktb7p4xyApuef1dwEBo/U/SYQzbGBvV+nru2Xg== - "@webassemblyjs/floating-point-hex-parser@1.8.5": version "1.8.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.8.5.tgz#1ba926a2923613edce496fd5b02e8ce8a5f49721" integrity sha512-9p+79WHru1oqBh9ewP9zW95E3XAo+90oth7S5Re3eQnECGq59ly1Ri5tsIipKGpiStHsUYmY3zMLqtk3gTcOtQ== -"@webassemblyjs/helper-api-error@1.7.11": - version "1.7.11" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.7.11.tgz#c7b6bb8105f84039511a2b39ce494f193818a32a" - integrity sha512-7r1qXLmiglC+wPNkGuXCvkmalyEstKVwcueZRP2GNC2PAvxbLYwLLPr14rcdJaE4UtHxQKfFkuDFuv91ipqvXg== - "@webassemblyjs/helper-api-error@1.8.5": version "1.8.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.8.5.tgz#c49dad22f645227c5edb610bdb9697f1aab721f7" integrity sha512-Za/tnzsvnqdaSPOUXHyKJ2XI7PDX64kWtURyGiJJZKVEdFOsdKUCPTNEVFZq3zJ2R0G5wc2PZ5gvdTRFgm81zA== -"@webassemblyjs/helper-buffer@1.7.11": - version "1.7.11" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.7.11.tgz#3122d48dcc6c9456ed982debe16c8f37101df39b" - integrity sha512-MynuervdylPPh3ix+mKZloTcL06P8tenNH3sx6s0qE8SLR6DdwnfgA7Hc9NSYeob2jrW5Vql6GVlsQzKQCa13w== - "@webassemblyjs/helper-buffer@1.8.5": version "1.8.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.8.5.tgz#fea93e429863dd5e4338555f42292385a653f204" integrity sha512-Ri2R8nOS0U6G49Q86goFIPNgjyl6+oE1abW1pS84BuhP1Qcr5JqMwRFT3Ah3ADDDYGEgGs1iyb1DGX+kAi/c/Q== -"@webassemblyjs/helper-code-frame@1.7.11": - version "1.7.11" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-code-frame/-/helper-code-frame-1.7.11.tgz#cf8f106e746662a0da29bdef635fcd3d1248364b" - integrity sha512-T8ESC9KMXFTXA5urJcyor5cn6qWeZ4/zLPyWeEXZ03hj/x9weSokGNkVCdnhSabKGYWxElSdgJ+sFa9G/RdHNw== - dependencies: - "@webassemblyjs/wast-printer" "1.7.11" - "@webassemblyjs/helper-code-frame@1.8.5": version "1.8.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-code-frame/-/helper-code-frame-1.8.5.tgz#9a740ff48e3faa3022b1dff54423df9aa293c25e" @@ -763,21 +1473,11 @@ dependencies: "@webassemblyjs/wast-printer" "1.8.5" -"@webassemblyjs/helper-fsm@1.7.11": - version "1.7.11" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-fsm/-/helper-fsm-1.7.11.tgz#df38882a624080d03f7503f93e3f17ac5ac01181" - integrity sha512-nsAQWNP1+8Z6tkzdYlXT0kxfa2Z1tRTARd8wYnc/e3Zv3VydVVnaeePgqUzFrpkGUyhUUxOl5ML7f1NuT+gC0A== - "@webassemblyjs/helper-fsm@1.8.5": version "1.8.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-fsm/-/helper-fsm-1.8.5.tgz#ba0b7d3b3f7e4733da6059c9332275d860702452" integrity sha512-kRuX/saORcg8se/ft6Q2UbRpZwP4y7YrWsLXPbbmtepKr22i8Z4O3V5QE9DbZK908dh5Xya4Un57SDIKwB9eow== -"@webassemblyjs/helper-module-context@1.7.11": - version "1.7.11" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-module-context/-/helper-module-context-1.7.11.tgz#d874d722e51e62ac202476935d649c802fa0e209" - integrity sha512-JxfD5DX8Ygq4PvXDucq0M+sbUFA7BJAv/GGl9ITovqE+idGX+J3QSzJYz+LwQmL7fC3Rs+utvWoJxDb6pmC0qg== - "@webassemblyjs/helper-module-context@1.8.5": version "1.8.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-module-context/-/helper-module-context-1.8.5.tgz#def4b9927b0101dc8cbbd8d1edb5b7b9c82eb245" @@ -786,26 +1486,11 @@ "@webassemblyjs/ast" "1.8.5" mamacro "^0.0.3" -"@webassemblyjs/helper-wasm-bytecode@1.7.11": - version "1.7.11" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.7.11.tgz#dd9a1e817f1c2eb105b4cf1013093cb9f3c9cb06" - integrity sha512-cMXeVS9rhoXsI9LLL4tJxBgVD/KMOKXuFqYb5oCJ/opScWpkCMEz9EJtkonaNcnLv2R3K5jIeS4TRj/drde1JQ== - "@webassemblyjs/helper-wasm-bytecode@1.8.5": version "1.8.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.8.5.tgz#537a750eddf5c1e932f3744206551c91c1b93e61" integrity sha512-Cu4YMYG3Ddl72CbmpjU/wbP6SACcOPVbHN1dI4VJNJVgFwaKf1ppeFJrwydOG3NDHxVGuCfPlLZNyEdIYlQ6QQ== -"@webassemblyjs/helper-wasm-section@1.7.11": - version "1.7.11" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.7.11.tgz#9c9ac41ecf9fbcfffc96f6d2675e2de33811e68a" - integrity sha512-8ZRY5iZbZdtNFE5UFunB8mmBEAbSI3guwbrsCl4fWdfRiAcvqQpeqd5KHhSWLL5wuxo53zcaGZDBU64qgn4I4Q== - dependencies: - "@webassemblyjs/ast" "1.7.11" - "@webassemblyjs/helper-buffer" "1.7.11" - "@webassemblyjs/helper-wasm-bytecode" "1.7.11" - "@webassemblyjs/wasm-gen" "1.7.11" - "@webassemblyjs/helper-wasm-section@1.8.5": version "1.8.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.8.5.tgz#74ca6a6bcbe19e50a3b6b462847e69503e6bfcbf" @@ -816,13 +1501,6 @@ "@webassemblyjs/helper-wasm-bytecode" "1.8.5" "@webassemblyjs/wasm-gen" "1.8.5" -"@webassemblyjs/ieee754@1.7.11": - version "1.7.11" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.7.11.tgz#c95839eb63757a31880aaec7b6512d4191ac640b" - integrity sha512-Mmqx/cS68K1tSrvRLtaV/Lp3NZWzXtOHUW2IvDvl2sihAwJh4ACE0eL6A8FvMyDG9abes3saB6dMimLOs+HMoQ== - dependencies: - "@xtuc/ieee754" "^1.2.0" - "@webassemblyjs/ieee754@1.8.5": version "1.8.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.8.5.tgz#712329dbef240f36bf57bd2f7b8fb9bf4154421e" @@ -830,13 +1508,6 @@ dependencies: "@xtuc/ieee754" "^1.2.0" -"@webassemblyjs/leb128@1.7.11": - version "1.7.11" - resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.7.11.tgz#d7267a1ee9c4594fd3f7e37298818ec65687db63" - integrity sha512-vuGmgZjjp3zjcerQg+JA+tGOncOnJLWVkt8Aze5eWQLwTQGNgVLcyOTqgSCxWTR4J42ijHbBxnuRaL1Rv7XMdw== - dependencies: - "@xtuc/long" "4.2.1" - "@webassemblyjs/leb128@1.8.5": version "1.8.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.8.5.tgz#044edeb34ea679f3e04cd4fd9824d5e35767ae10" @@ -844,30 +1515,11 @@ dependencies: "@xtuc/long" "4.2.2" -"@webassemblyjs/utf8@1.7.11": - version "1.7.11" - resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.7.11.tgz#06d7218ea9fdc94a6793aa92208160db3d26ee82" - integrity sha512-C6GFkc7aErQIAH+BMrIdVSmW+6HSe20wg57HEC1uqJP8E/xpMjXqQUxkQw07MhNDSDcGpxI9G5JSNOQCqJk4sA== - "@webassemblyjs/utf8@1.8.5": version "1.8.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.8.5.tgz#a8bf3b5d8ffe986c7c1e373ccbdc2a0915f0cedc" integrity sha512-U7zgftmQriw37tfD934UNInokz6yTmn29inT2cAetAsaU9YeVCveWEwhKL1Mg4yS7q//NGdzy79nlXh3bT8Kjw== -"@webassemblyjs/wasm-edit@1.7.11": - version "1.7.11" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.7.11.tgz#8c74ca474d4f951d01dbae9bd70814ee22a82005" - integrity sha512-FUd97guNGsCZQgeTPKdgxJhBXkUbMTY6hFPf2Y4OedXd48H97J+sOY2Ltaq6WGVpIH8o/TGOVNiVz/SbpEMJGg== - dependencies: - "@webassemblyjs/ast" "1.7.11" - "@webassemblyjs/helper-buffer" "1.7.11" - "@webassemblyjs/helper-wasm-bytecode" "1.7.11" - "@webassemblyjs/helper-wasm-section" "1.7.11" - "@webassemblyjs/wasm-gen" "1.7.11" - "@webassemblyjs/wasm-opt" "1.7.11" - "@webassemblyjs/wasm-parser" "1.7.11" - "@webassemblyjs/wast-printer" "1.7.11" - "@webassemblyjs/wasm-edit@1.8.5": version "1.8.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.8.5.tgz#962da12aa5acc1c131c81c4232991c82ce56e01a" @@ -882,17 +1534,6 @@ "@webassemblyjs/wasm-parser" "1.8.5" "@webassemblyjs/wast-printer" "1.8.5" -"@webassemblyjs/wasm-gen@1.7.11": - version "1.7.11" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.7.11.tgz#9bbba942f22375686a6fb759afcd7ac9c45da1a8" - integrity sha512-U/KDYp7fgAZX5KPfq4NOupK/BmhDc5Kjy2GIqstMhvvdJRcER/kUsMThpWeRP8BMn4LXaKhSTggIJPOeYHwISA== - dependencies: - "@webassemblyjs/ast" "1.7.11" - "@webassemblyjs/helper-wasm-bytecode" "1.7.11" - "@webassemblyjs/ieee754" "1.7.11" - "@webassemblyjs/leb128" "1.7.11" - "@webassemblyjs/utf8" "1.7.11" - "@webassemblyjs/wasm-gen@1.8.5": version "1.8.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.8.5.tgz#54840766c2c1002eb64ed1abe720aded714f98bc" @@ -904,16 +1545,6 @@ "@webassemblyjs/leb128" "1.8.5" "@webassemblyjs/utf8" "1.8.5" -"@webassemblyjs/wasm-opt@1.7.11": - version "1.7.11" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.7.11.tgz#b331e8e7cef8f8e2f007d42c3a36a0580a7d6ca7" - integrity sha512-XynkOwQyiRidh0GLua7SkeHvAPXQV/RxsUeERILmAInZegApOUAIJfRuPYe2F7RcjOC9tW3Cb9juPvAC/sCqvg== - dependencies: - "@webassemblyjs/ast" "1.7.11" - "@webassemblyjs/helper-buffer" "1.7.11" - "@webassemblyjs/wasm-gen" "1.7.11" - "@webassemblyjs/wasm-parser" "1.7.11" - "@webassemblyjs/wasm-opt@1.8.5": version "1.8.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.8.5.tgz#b24d9f6ba50394af1349f510afa8ffcb8a63d264" @@ -924,18 +1555,6 @@ "@webassemblyjs/wasm-gen" "1.8.5" "@webassemblyjs/wasm-parser" "1.8.5" -"@webassemblyjs/wasm-parser@1.7.11": - version "1.7.11" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.7.11.tgz#6e3d20fa6a3519f6b084ef9391ad58211efb0a1a" - integrity sha512-6lmXRTrrZjYD8Ng8xRyvyXQJYUQKYSXhJqXOBLw24rdiXsHAOlvw5PhesjdcaMadU/pyPQOJ5dHreMjBxwnQKg== - dependencies: - "@webassemblyjs/ast" "1.7.11" - "@webassemblyjs/helper-api-error" "1.7.11" - "@webassemblyjs/helper-wasm-bytecode" "1.7.11" - "@webassemblyjs/ieee754" "1.7.11" - "@webassemblyjs/leb128" "1.7.11" - "@webassemblyjs/utf8" "1.7.11" - "@webassemblyjs/wasm-parser@1.8.5": version "1.8.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.8.5.tgz#21576f0ec88b91427357b8536383668ef7c66b8d" @@ -948,18 +1567,6 @@ "@webassemblyjs/leb128" "1.8.5" "@webassemblyjs/utf8" "1.8.5" -"@webassemblyjs/wast-parser@1.7.11": - version "1.7.11" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-parser/-/wast-parser-1.7.11.tgz#25bd117562ca8c002720ff8116ef9072d9ca869c" - integrity sha512-lEyVCg2np15tS+dm7+JJTNhNWq9yTZvi3qEhAIIOaofcYlUp0UR5/tVqOwa/gXYr3gjwSZqw+/lS9dscyLelbQ== - dependencies: - "@webassemblyjs/ast" "1.7.11" - "@webassemblyjs/floating-point-hex-parser" "1.7.11" - "@webassemblyjs/helper-api-error" "1.7.11" - "@webassemblyjs/helper-code-frame" "1.7.11" - "@webassemblyjs/helper-fsm" "1.7.11" - "@xtuc/long" "4.2.1" - "@webassemblyjs/wast-parser@1.8.5": version "1.8.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-parser/-/wast-parser-1.8.5.tgz#e10eecd542d0e7bd394f6827c49f3df6d4eefb8c" @@ -972,15 +1579,6 @@ "@webassemblyjs/helper-fsm" "1.8.5" "@xtuc/long" "4.2.2" -"@webassemblyjs/wast-printer@1.7.11": - version "1.7.11" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.7.11.tgz#c4245b6de242cb50a2cc950174fdbf65c78d7813" - integrity sha512-m5vkAsuJ32QpkdkDOUPGSltrg8Cuk3KBx4YrmAGQwCZPRdUHXxG4phIOuuycLemHFr74sWL9Wthqss4fzdzSwg== - dependencies: - "@webassemblyjs/ast" "1.7.11" - "@webassemblyjs/wast-parser" "1.7.11" - "@xtuc/long" "4.2.1" - "@webassemblyjs/wast-printer@1.8.5": version "1.8.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.8.5.tgz#114bbc481fd10ca0e23b3560fa812748b0bae5bc" @@ -995,11 +1593,6 @@ resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" integrity sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA== -"@xtuc/long@4.2.1": - version "4.2.1" - resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.1.tgz#5c85d662f76fa1d34575766c5dcd6615abcd30d8" - integrity sha512-FZdkNBDqBRHKQ2MEbSC17xnPFOhZxeJ2YGSfr2BKf3sujG49Qe3bB+rGCwQfIaA7WHnGeGkSijX4FuBCdrzW/g== - "@xtuc/long@4.2.2": version "4.2.2" resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" @@ -1044,11 +1637,6 @@ accepts@~1.3.7: mime-types "~2.1.24" negotiator "0.6.2" -acorn-dynamic-import@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/acorn-dynamic-import/-/acorn-dynamic-import-4.0.0.tgz#482210140582a36b83c3e342e1cfebcaa9240948" - integrity sha512-d3OEjQV4ROpoflsnUA8HozoIR504TFxNivYEUi6uwz0IYhBkTDXGuWlNdMtybRt3nqVx/L6XqMt0FxkXuWKZhw== - acorn-jsx@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.1.0.tgz#294adb71b57398b0680015f0a38c563ee1db5384" @@ -1064,16 +1652,16 @@ acorn@^5.5.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.2.tgz#91fa871883485d06708800318404e72bfb26dcc5" integrity sha512-cJrKCNcr2kv8dlDnbw+JPUGjHZzo4myaxOLmpOX8a+rgX94YeTcTMv/LFJUSByRpc+i4GgVnnhLxvMu/2Y+rqw== -acorn@^6.0.5, acorn@^6.2.1: - version "6.4.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.0.tgz#b659d2ffbafa24baf5db1cdbb2c94a983ecd2784" - integrity sha512-gac8OEcQ2Li1dxIEWGZzsp2BitJxwkwcOm0zHAJLcPJaVvm58FRnk6RkuLRpU1EujipU2ZFODv2P9DLMfnV8mw== - acorn@^6.0.7: version "6.1.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.1.1.tgz#7d25ae05bb8ad1f9b699108e1094ecd7884adc1f" integrity sha512-jPTiwtOxaHNaAPg/dmrJ/beuzLRnXtB0kQPQ8JpotKJgTB6rX6c8mlf315941pyjBSaPg8NHXS9fhP4u17DpGA== +acorn@^6.2.1: + version "6.4.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.0.tgz#b659d2ffbafa24baf5db1cdbb2c94a983ecd2784" + integrity sha512-gac8OEcQ2Li1dxIEWGZzsp2BitJxwkwcOm0zHAJLcPJaVvm58FRnk6RkuLRpU1EujipU2ZFODv2P9DLMfnV8mw== + acorn@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.1.0.tgz#949d36f2c292535da602283586c2477c57eb2d6c" @@ -1089,7 +1677,7 @@ after@0.8.2: resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f" integrity sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8= -agent-base@4: +agent-base@4, agent-base@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee" integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg== @@ -1133,10 +1721,10 @@ ajv-keywords@^3.4.1: resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.4.1.tgz#ef916e271c64ac12171fd8384eaae6b2345854da" integrity sha512-RO1ibKvd27e6FEShVFfPALuHI3WjSVNeK5FIsmme/LYRNxjKuNj+Dt7bucLa6NdSv3JcVTyMlm9kGR84z1XpaQ== -ajv@6.9.1: - version "6.9.1" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.9.1.tgz#a4d3683d74abc5670e75f0b16520f70a20ea8dc1" - integrity sha512-XDN92U311aINL77ieWHmqCcNlwjoP5cHXDxIxbf2MaPYuCXOHS7gHH8jktxeK5omgd52XbSTX6a4Piwd1pQmzA== +ajv@6.10.2, ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2: + version "6.10.2" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.2.tgz#d3cea04d6b017b2894ad69040fec8b623eb4bd52" + integrity sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw== dependencies: fast-deep-equal "^2.0.1" fast-json-stable-stringify "^2.0.0" @@ -1153,16 +1741,6 @@ ajv@^5.0.0, ajv@^5.3.0: fast-json-stable-stringify "^2.0.0" json-schema-traverse "^0.3.0" -ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2: - version "6.10.2" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.2.tgz#d3cea04d6b017b2894ad69040fec8b623eb4bd52" - integrity sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw== - dependencies: - fast-deep-equal "^2.0.1" - fast-json-stable-stringify "^2.0.0" - json-schema-traverse "^0.4.1" - uri-js "^4.2.2" - ajv@^6.1.1: version "6.5.3" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.5.3.tgz#71a569d189ecf4f4f321224fecb166f071dd90f9" @@ -1198,11 +1776,6 @@ angular-idle-preload@3.0.0: resolved "https://registry.yarnpkg.com/angular-idle-preload/-/angular-idle-preload-3.0.0.tgz#decace34d9fac1cb00000727a6dc5caafdb84e4d" integrity sha512-W3P2m2B6MHdt1DVunH6H3VWkAZrG3ZwxGcPjedVvIyRhg/LmMtILoizHSxTXw3fsKIEdAPwGObXGpML9WD1jJA== -angular-sortablejs@^2.5.0: - version "2.6.0" - resolved "https://registry.yarnpkg.com/angular-sortablejs/-/angular-sortablejs-2.6.0.tgz#d41a5dcaf1dd08bcd79677b1fc0c64fb872fe2d3" - integrity sha512-f/W5WUeySMLhMqUHpAqzHCi3+yj6uwPcwr5FcKRRF+o5nzuYR5ppW1GJJk/Px1Q0HhL2//z9O3QZpEVMlvHf5w== - angular2-template-loader@0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/angular2-template-loader/-/angular2-template-loader-0.6.2.tgz#c0d44e90fff0fac95e8b23f043acda7fd1c51d7c" @@ -1231,6 +1804,11 @@ ansi-align@^2.0.0: dependencies: string-width "^2.0.0" +ansi-colors@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" + integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== + ansi-colors@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-1.1.0.tgz#6374b4dd5d4718ff3ce27a671a3b1cad077132a9" @@ -1255,11 +1833,6 @@ ansi-escapes@^1.1.0: resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e" integrity sha1-06ioOzGapneTZisT52HHkRQiMG4= -ansi-escapes@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.1.0.tgz#f73207bb81207d75fd6c83f125af26eea378ca30" - integrity sha512-UgAb8H9D41AQnu/PbWlCofQVcnV4Gs2bBJi9eZPxfU/hgglFh3SMDMENRIqdr7H6XFnXdoknctFByVsCOotTVw== - ansi-escapes@^4.2.1: version "4.3.0" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.0.tgz#a4ce2b33d6b214b7950d8595c212f12ac9cc569d" @@ -1336,12 +1909,20 @@ anymatch@^2.0.0: micromatch "^3.1.4" normalize-path "^2.1.1" +anymatch@~3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.1.tgz#c55ecf02185e2469259399310c173ce31233b142" + integrity sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + app-root-path@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/app-root-path/-/app-root-path-2.2.1.tgz#d0df4a682ee408273583d43f6f79e9892624bc9a" integrity sha512-91IFKeKk7FjfmezPKkwtaRvSpnUc4gDwPAjA1YZ9Gn0q0PPeW+vbeUsZuyDwjI7+QTHhcLen2v25fi/AmhvbJA== -aproba@^1.0.3, aproba@^1.1.1: +aproba@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== @@ -1375,14 +1956,6 @@ archiver@^3.0.0: tar-stream "^2.1.0" zip-stream "^2.1.2" -are-we-there-yet@~1.1.2: - version "1.1.5" - resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21" - integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w== - dependencies: - delegates "^1.0.0" - readable-stream "^2.0.6" - argparse@^1.0.7: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -1505,7 +2078,7 @@ arrify@^1.0.0, arrify@^1.0.1: resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0= -asap@~2.0.3: +asap@^2.0.0, asap@~2.0.3: version "2.0.6" resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= @@ -1563,11 +2136,6 @@ async-each@^1.0.1: resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf" integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ== -async-foreach@^0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/async-foreach/-/async-foreach-0.1.3.tgz#36121f845c0578172de419a97dbeb1d16ec34542" - integrity sha1-NhIfhFwFeBct5Bmpfb6x0W7DRUI= - async-limiter@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.0.tgz#78faed8c3d074ab81f22b4e985d79e8738f720f8" @@ -1609,17 +2177,18 @@ atob@^2.1.1: resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== -autoprefixer@9.4.6: - version "9.4.6" - resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.4.6.tgz#0ace275e33b37de16b09a5547dbfe73a98c1d446" - integrity sha512-Yp51mevbOEdxDUy5WjiKtpQaecqYq9OqZSL04rSoCiry7Tc5I9FEyo3bfxiTJc1DfHeKwSFCUYbBAiOQ2VGfiw== +autoprefixer@9.6.1: + version "9.6.1" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.6.1.tgz#51967a02d2d2300bb01866c1611ec8348d355a47" + integrity sha512-aVo5WxR3VyvyJxcJC3h4FKfwCQvQWb1tSI5VHNibddCVWrcD1NvlxEweg3TSgiPztMnWfjpy2FURKA2kvDE+Tw== dependencies: - browserslist "^4.4.1" - caniuse-lite "^1.0.30000929" + browserslist "^4.6.3" + caniuse-lite "^1.0.30000980" + chalk "^2.4.2" normalize-range "^0.1.2" num2fraction "^1.2.2" - postcss "^7.0.13" - postcss-value-parser "^3.3.1" + postcss "^7.0.17" + postcss-value-parser "^4.0.0" autoprefixer@^7.1.1: version "7.2.6" @@ -1693,6 +2262,13 @@ babel-messages@^6.23.0: dependencies: babel-runtime "^6.22.0" +babel-plugin-dynamic-import-node@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.0.tgz#f00f507bdaa3c3e3ff6e7e5e98d90a7acab96f7f" + integrity sha512-o6qFkpeQEBxcqt0XYlWzAVxNCSCZdUgcR8IRlhD/8DylxjjO4foPcvTW0GGKa/cVt3rvxZ7o5ippJ+/0nvLhlQ== + dependencies: + object.assign "^4.1.0" + babel-polyfill@6.23.0: version "6.23.0" resolved "https://registry.yarnpkg.com/babel-polyfill/-/babel-polyfill-6.23.0.tgz#8364ca62df8eafb830499f699177466c3b03499d" @@ -1855,6 +2431,18 @@ binary-extensions@^1.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.11.0.tgz#46aa1751fb6a2f93ee5e689bb1087d4b14c6c205" integrity sha1-RqoXUftqL5PuXmibsQh9SxTGwgU= +binary-extensions@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.0.0.tgz#23c0df14f6a88077f5f986c0d167ec03c3d5537c" + integrity sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow== + +bindings@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" + integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== + dependencies: + file-uri-to-path "1.0.0" + bl@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/bl/-/bl-3.0.0.tgz#3611ec00579fd18561754360b21e9f784500ff88" @@ -1867,13 +2455,6 @@ blob@0.0.4: resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.4.tgz#bcf13052ca54463f30f9fc7e95b9a47630a94921" integrity sha1-vPEwUspURj8w+fx+lbmkdjCpSSE= -block-stream@*: - version "0.0.9" - resolved "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a" - integrity sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo= - dependencies: - inherits "~2.0.0" - blocking-proxy@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/blocking-proxy/-/blocking-proxy-1.0.1.tgz#81d6fd1fe13a4c0d6957df7f91b75e98dac40cb2" @@ -2012,6 +2593,13 @@ braces@^2.3.0, braces@^2.3.1, braces@^2.3.2: split-string "^3.0.2" to-regex "^3.0.1" +braces@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" + integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== + dependencies: + fill-range "^7.0.1" + brorand@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" @@ -2081,6 +2669,15 @@ browserify-zlib@^0.2.0: dependencies: pako "~1.0.5" +browserslist@4.8.6, browserslist@^4.6.3, browserslist@^4.8.2, browserslist@^4.8.3, browserslist@^4.8.5: + version "4.8.6" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.8.6.tgz#96406f3f5f0755d272e27a66f4163ca821590a7e" + integrity sha512-ZHao85gf0eZ0ESxLfCp73GG9O/VTytYDIkIiZDlURppLTI9wErSM/5yAKEq6rcUdxBLjMELmrYUJGg5sxGKMHg== + dependencies: + caniuse-lite "^1.0.30001023" + electron-to-chromium "^1.3.341" + node-releases "^1.1.47" + browserslist@^2.0.0, browserslist@^2.11.3: version "2.11.3" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-2.11.3.tgz#fe36167aed1bbcde4827ebfe71347a2cc70b99b2" @@ -2107,15 +2704,6 @@ browserslist@^4.0.2: electron-to-chromium "^1.3.61" node-releases "^1.0.0-alpha.11" -browserslist@^4.4.1: - version "4.8.2" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.8.2.tgz#b45720ad5fbc8713b7253c20766f701c9a694289" - integrity sha512-+M4oeaTplPm/f1pXDw84YohEv7B1i/2Aisei8s4s6k3QsoSHa7i5sz8u/cGQkkatCPxMASKxPualR4wwYgVboA== - dependencies: - caniuse-lite "^1.0.30001015" - electron-to-chromium "^1.3.322" - node-releases "^1.1.42" - browserstack@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/browserstack/-/browserstack-1.5.1.tgz#e2dfa66ffee940ebad0a07f7e00fd4687c455d66" @@ -2213,35 +2801,17 @@ bytes@3.1.0: resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6" integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg== -cacache@^10.0.4: - version "10.0.4" - resolved "https://registry.yarnpkg.com/cacache/-/cacache-10.0.4.tgz#6452367999eff9d4188aefd9a14e9d7c6a263460" - integrity sha512-Dph0MzuH+rTQzGPNT9fAnrPmMmjKfST6trxJeK7NQuHRaVw24VzPRWTmg9MpcwOVQZO0E1FBICUlFeNaKPIfHA== - dependencies: - bluebird "^3.5.1" - chownr "^1.0.1" - glob "^7.1.2" - graceful-fs "^4.1.11" - lru-cache "^4.1.1" - mississippi "^2.0.0" - mkdirp "^0.5.1" - move-concurrently "^1.0.1" - promise-inflight "^1.0.1" - rimraf "^2.6.2" - ssri "^5.2.4" - unique-filename "^1.1.0" - y18n "^4.0.0" - -cacache@^11.0.2, cacache@^11.3.2, cacache@^11.3.3: - version "11.3.3" - resolved "https://registry.yarnpkg.com/cacache/-/cacache-11.3.3.tgz#8bd29df8c6a718a6ebd2d010da4d7972ae3bbadc" - integrity sha512-p8WcneCytvzPxhDvYp31PD039vi77I12W+/KfR9S8AZbaiARFBCpsPJS+9uhWfeBfeAtW7o/4vt3MUqLkbY6nA== +cacache@12.0.2: + version "12.0.2" + resolved "https://registry.yarnpkg.com/cacache/-/cacache-12.0.2.tgz#8db03205e36089a3df6954c66ce92541441ac46c" + integrity sha512-ifKgxH2CKhJEg6tNdAwziu6Q33EvuG26tYcda6PT3WKisZcYDXsnEdnRv67Po3yCzFfaSoMjGZzJyD2c3DT1dg== dependencies: bluebird "^3.5.5" chownr "^1.1.1" figgy-pudding "^3.5.1" glob "^7.1.4" graceful-fs "^4.1.15" + infer-owner "^1.0.3" lru-cache "^5.1.1" mississippi "^3.0.0" mkdirp "^0.5.1" @@ -2252,7 +2822,7 @@ cacache@^11.0.2, cacache@^11.3.2, cacache@^11.3.3: unique-filename "^1.1.1" y18n "^4.0.0" -cacache@^12.0.2, cacache@^12.0.3: +cacache@^12.0.0, cacache@^12.0.2, cacache@^12.0.3: version "12.0.3" resolved "https://registry.yarnpkg.com/cacache/-/cacache-12.0.3.tgz#be99abba4e1bf5df461cd5a2c1071fc432573390" integrity sha512-kqdmfXEGFepesTuROHMs3MpFLWrPkSSpRqOw80RCflZXy/khxaArvFrQ7uJxSUduzAufc6G0g1VUCOZXxWavPw== @@ -2367,12 +2937,7 @@ camelcase@^2.0.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f" integrity sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8= -camelcase@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-3.0.0.tgz#32fc4b9fcdaf845fcdf7e73bb97cac2261f0ab0a" - integrity sha1-MvxLn82vhF/N9+c7uXysImHwqwo= - -camelcase@^4.0.0, camelcase@^4.1.0: +camelcase@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd" integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0= @@ -2402,21 +2967,26 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" +caniuse-lite@1.0.30001024: + version "1.0.30001024" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001024.tgz#7feb6793fd5c9d7e0d4c01c80321855592a46b73" + integrity sha512-LubRSEPpOlKlhZw9wGlLHo8ZVj6ugGU3xGUfLPneNBledSd9lIM5cCGZ9Mz/mMCJUhEt4jZpYteZNVRdJw5FRA== + caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000697, caniuse-lite@^1.0.30000792, caniuse-lite@^1.0.30000805, caniuse-lite@^1.0.30000878: version "1.0.30000883" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000883.tgz#597c1eabfb379bd9fbeaa778632762eb574706ac" integrity sha512-ovvb0uya4cKJct8Rj9Olstz0LaWmyJhCp3NawRG5fVigka8pEhIIwipF7zyYd2Q58UZb5YfIt52pVF444uj2kQ== -caniuse-lite@^1.0.30000929, caniuse-lite@^1.0.30001015: - version "1.0.30001016" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001016.tgz#16ea48d7d6e8caf3cad3295c2d746fe38c4e7f66" - integrity sha512-yYQ2QfotceRiH4U+h1Us86WJXtVHDmy3nEKIdYPsZCYnOV5/tMgGbmoIlrMzmh2VXlproqYtVaKeGDBkMZifFA== - caniuse-lite@^1.0.30000939: version "1.0.30000948" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000948.tgz#793ed7c28fe664856beb92b43fc013fc22b81633" integrity sha512-Lw4y7oz1X5MOMZm+2IFaSISqVVQvUuD+ZUSfeYK/SlYiMjkHN/eJ2PDfJehW5NA6JjrxYSSnIWfwjeObQMEjFQ== +caniuse-lite@^1.0.30000980, caniuse-lite@^1.0.30001023: + version "1.0.30001025" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001025.tgz#30336a8aca7f98618eb3cf38e35184e13d4e5fe6" + integrity sha512-SKyFdHYfXUZf5V85+PJgLYyit27q4wgvZuf8QTOk1osbypcROihMBlx9GRar2/pIcKH2r4OehdlBr9x6PXetAQ== + canonical-path@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/canonical-path/-/canonical-path-1.0.0.tgz#fcb470c23958def85081856be7a86e904f180d1d" @@ -2479,7 +3049,22 @@ check-types@^7.3.0: resolved "https://registry.yarnpkg.com/check-types/-/check-types-7.4.0.tgz#0378ec1b9616ec71f774931a3c6516fad8c152f4" integrity sha512-YbulWHdfP99UfZ73NcUDlNJhEIDgm9Doq9GhpyXbF+7Aegi3CVV7qqMCKTTqJxlvEvnQBp9IA+dxsGN6xK/nSg== -chokidar@2.0.4, chokidar@^2.0.0, chokidar@^2.0.2, chokidar@^2.0.3, chokidar@^2.0.4: +"chokidar@>=2.0.0 <4.0.0": + version "3.3.1" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.3.1.tgz#c84e5b3d18d9a4d77558fef466b1bf16bbeb3450" + integrity sha512-4QYCEWOcK3OJrxwvyyAOxFuhpvOVCYkr33LPfFNBjAD/w3sEzWsp2BUOkI4l9bHvWioAd0rc6NlHUOEaWkTeqg== + dependencies: + anymatch "~3.1.1" + braces "~3.0.2" + glob-parent "~5.1.0" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.3.0" + optionalDependencies: + fsevents "~2.1.2" + +chokidar@^2.0.0, chokidar@^2.0.2, chokidar@^2.0.3, chokidar@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.0.4.tgz#356ff4e2b0e8e43e322d18a372460bbcf3accd26" integrity sha512-z9n7yt9rOvIJrMhvDtDictKrkFHeihkNl6uWMmZlmL6tJtX9Cs+87oK+teBx+JIgzvbX3yZHT3eF8vpbDxHJXQ== @@ -2499,7 +3084,7 @@ chokidar@2.0.4, chokidar@^2.0.0, chokidar@^2.0.2, chokidar@^2.0.3, chokidar@^2.0 optionalDependencies: fsevents "^1.2.2" -chokidar@^2.1.1: +chokidar@^2.1.1, chokidar@^2.1.8: version "2.1.8" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917" integrity sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg== @@ -2537,23 +3122,11 @@ chokidar@^2.1.6: optionalDependencies: fsevents "^1.2.7" -chownr@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.0.1.tgz#e2a75042a9551908bebd25b8523d5f9769d79181" - integrity sha1-4qdQQqlVGQi+vSW4Uj1fl2nXkYE= - chownr@^1.1.1, chownr@^1.1.2: version "1.1.3" resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.3.tgz#42d837d5239688d55f303003a508230fa6727142" integrity sha512-i70fVHhmV3DtTl6nqvZOnIjbY0Pe4kAUjwHj8z0zAdgBtYrJyYwLKCCuRBQ5ppkyL0AkN7HKRnETdmdp1zqNXw== -chrome-trace-event@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.0.tgz#45a91bd2c20c9411f0963b5aaeb9a1b95e09cc48" - integrity sha512-xDbVgyfDTT2piup/h8dK/y4QZfJRSa73bw1WZ8b4XM1o7fsFubUVGYcE+1ANtOzJJELGpYoG2961z0Z6OAld9A== - dependencies: - tslib "^1.9.0" - chrome-trace-event@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.2.tgz#234090ee97c7d4ad1a2c4beae27505deffc608a4" @@ -2574,10 +3147,10 @@ cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: inherits "^2.0.1" safe-buffer "^5.0.1" -circular-dependency-plugin@5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/circular-dependency-plugin/-/circular-dependency-plugin-5.0.2.tgz#da168c0b37e7b43563fb9f912c1c007c213389ef" - integrity sha512-oC7/DVAyfcY3UWKm0sN/oVoDedQDQiw/vIiAnuTWTpE5s0zWf7l3WY417Xw/Fbi/QbAjctAkxgMiS9P0s3zkmA== +circular-dependency-plugin@5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/circular-dependency-plugin/-/circular-dependency-plugin-5.2.0.tgz#e09dbc2dd3e2928442403e2d45b41cea06bc0a93" + integrity sha512-7p4Kn/gffhQaavNfyDFg7LS5S/UT1JAjyGd4UqR2+jzoYF02eDkj0Ec3+48TsIa4zghjLY87nQHIh/ecK9qLdw== circular-json@^0.5.0: version "0.5.9" @@ -2638,15 +3211,6 @@ cli-width@^2.0.0: resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639" integrity sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk= -cliui@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-3.2.0.tgz#120601537a916d29940f934da3b48d585a39213d" - integrity sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0= - dependencies: - string-width "^1.0.1" - strip-ansi "^3.0.1" - wrap-ansi "^2.0.0" - cliui@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/cliui/-/cliui-4.1.0.tgz#348422dbe82d800b3022eef4f6ac10bf2e4d1b49" @@ -2665,16 +3229,6 @@ cliui@^5.0.0: strip-ansi "^5.2.0" wrap-ansi "^5.1.0" -clone-deep@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-2.0.2.tgz#00db3a1e173656730d1188c3d6aced6d7ea97713" - integrity sha512-SZegPTKjCgpQH63E+eN6mVEEPdQBOUzjyJm5Pora4lrwWRFS8I0QAxV/KD6vV/i0WuijHZWQC1fMsPEdxfdVCQ== - dependencies: - for-own "^1.0.0" - is-plain-object "^2.0.4" - kind-of "^6.0.0" - shallow-clone "^1.0.0" - clone-deep@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387" @@ -2843,7 +3397,7 @@ commander@2.17.x, commander@~2.17.1: resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf" integrity sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg== -commander@^2.11.0, commander@^2.19.0, commander@^2.20.0, commander@~2.20.3: +commander@^2.11.0, commander@^2.20.0, commander@~2.20.3: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== @@ -2932,7 +3486,7 @@ compression@1.7.1: safe-buffer "5.1.1" vary "~1.1.2" -compression@^1.5.2, compression@^1.7.4: +compression@^1.7.4: version "1.7.4" resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.4.tgz#95523eff170ca57c29a0ca41e6fe131f41e5bb8f" integrity sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ== @@ -2972,7 +3526,7 @@ configstore@^3.0.0: write-file-atomic "^2.0.0" xdg-basedir "^3.0.0" -connect-history-api-fallback@^1.3.0, connect-history-api-fallback@^1.6.0: +connect-history-api-fallback@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz#8b32089359308d111115d81cad3fceab888f97bc" integrity sha512-e54B99q/OUoH64zYYRf3HBP5z24G38h5D3qXu23JGRoigpX5Ss4r9ZnDk3g0Z8uQC2x2lPaJ+UlWBc1ZWBWdLg== @@ -2994,11 +3548,6 @@ console-browserify@^1.1.0: dependencies: date-now "^0.1.4" -console-control-strings@^1.0.0, console-control-strings@~1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" - integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= - constants-browserify@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" @@ -3026,6 +3575,13 @@ convert-source-map@^1.5.0, convert-source-map@^1.5.1: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.5.1.tgz#b8278097b9bc229365de5c62cf5fcaed8b5599e5" integrity sha1-uCeAl7m8IpNl3lxiz1/K7YtVmeU= +convert-source-map@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442" + integrity sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA== + dependencies: + safe-buffer "~5.1.1" + cookie-parser@1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/cookie-parser/-/cookie-parser-1.4.3.tgz#0fe31fa19d000b95f4aadf1f53fdc2b8a203baa5" @@ -3066,21 +3622,7 @@ copy-descriptor@^0.1.0: resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= -copy-webpack-plugin@4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-4.6.0.tgz#e7f40dd8a68477d405dd1b7a854aae324b158bae" - integrity sha512-Y+SQCF+0NoWQryez2zXn5J5knmr9z/9qSQt7fbL78u83rxmigOy8X5+BFn8CFSuX+nKT8gpYwJX68ekqtQt6ZA== - dependencies: - cacache "^10.0.4" - find-cache-dir "^1.0.0" - globby "^7.1.1" - is-glob "^4.0.0" - loader-utils "^1.1.0" - minimatch "^3.0.4" - p-limit "^1.0.0" - serialize-javascript "^1.4.0" - -copy-webpack-plugin@^5.1.1: +copy-webpack-plugin@5.1.1, copy-webpack-plugin@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-5.1.1.tgz#5481a03dea1123d88a988c6ff8b78247214f0b88" integrity sha512-P15M5ZC8dyCjQHWwd4Ia/dm0SgVvZJMYeykVIVYXbGyqO4dWB5oyPHp9i7wjwo5LhtlhKbiBCdS2NvM07Wlybg== @@ -3110,21 +3652,29 @@ copyfiles@^2.1.1: through2 "^2.0.1" yargs "^13.2.4" +core-js-compat@^3.6.2: + version "3.6.4" + resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.6.4.tgz#938476569ebb6cda80d339bcf199fae4f16fff17" + integrity sha512-zAa3IZPvsJ0slViBQ2z+vgyyTuhd3MFn1rBQjZSKVEgB0UMYhUkCj9jJUVPgGTGqWvsBVmfnruXgTcNyTlEiSA== + dependencies: + browserslist "^4.8.3" + semver "7.0.0" + core-js-pure@^3.0.0: version "3.5.0" resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.5.0.tgz#f63c7f2b245e7d678e73f87ad28505480554d70e" integrity sha512-wB0QtKAofWigiISuT1Tej3hKgq932fB//Lf1VoPbiLpTYlHY0nIDhgF+q1na0DAKFHH5wGCirkAknOmDN8ijXA== +core-js@3.6.4, core-js@^3.6.4: + version "3.6.4" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.6.4.tgz#440a83536b458114b9cb2ac1580ba377dc470647" + integrity sha512-4paDGScNgZP2IXXilaffL9X7968RuvwlkK3xWtZRVqgd8SYNiVKRJvkFd1aqqEuPfN7E68ZHEp9hDj6lHj4Hyw== + core-js@^2.2.0, core-js@^2.4.0: version "2.5.7" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.7.tgz#f972608ff0cead68b841a16a932d0b183791814e" integrity sha512-RszJCAxg/PP6uzXVXL6BsxSXx/B05oJAQ2vkJRjyjrEcNVycaqOmNb5OTxZPE3xa5gwZduqza6L9JOCenh/Ecw== -core-js@^2.6.5: - version "2.6.11" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" - integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg== - core-js@~2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.3.0.tgz#fab83fbb0b2d8dc85fa636c4b9d34c75420c6d65" @@ -3150,6 +3700,17 @@ cosmiconfig@^5.0.0: js-yaml "^3.13.1" parse-json "^4.0.0" +coverage-istanbul-loader@2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/coverage-istanbul-loader/-/coverage-istanbul-loader-2.0.3.tgz#87d42f03fa0fd3fa8743ec76945d9d67f105722a" + integrity sha512-LiGRvyIuzVYs3M1ZYK1tF0HekjH0DJ8zFdUwAZq378EJzqOgToyb1690dp3TAUlP6Y+82uu42LRjuROVeJ54CA== + dependencies: + convert-source-map "^1.7.0" + istanbul-lib-instrument "^4.0.0" + loader-utils "^1.2.3" + merge-source-map "^1.1.0" + schema-utils "^2.6.1" + coveralls@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/coveralls/-/coveralls-3.0.0.tgz#22ef730330538080d29b8c151dc9146afde88a99" @@ -3230,14 +3791,6 @@ cross-spawn@6.0.5, cross-spawn@^6.0.0, cross-spawn@^6.0.4, cross-spawn@^6.0.5: shebang-command "^1.2.0" which "^1.2.9" -cross-spawn@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-3.0.1.tgz#1256037ecb9f0c5f79e3d6ef135e30770184b982" - integrity sha1-ElYDfsufDF9549bvE14wdwGEuYI= - dependencies: - lru-cache "^4.0.1" - which "^1.2.9" - cross-spawn@^5.0.1: version "5.1.0" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" @@ -3561,7 +4114,7 @@ debug@*, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1: dependencies: ms "^2.1.1" -debug@2.6.9, debug@^2.1.2, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8: +debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== @@ -3575,25 +4128,23 @@ debug@3.1.0, debug@^3.1.0, debug@~3.1.0: dependencies: ms "2.0.0" -debug@^3.1.1, debug@^3.2.5, debug@^3.2.6: +debug@^3.0.0, debug@^3.1.1, debug@^3.2.5, debug@^3.2.6: version "3.2.6" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== dependencies: ms "^2.1.1" -decamelize@^1.1.1, decamelize@^1.1.2, decamelize@^1.2.0: +debuglog@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492" + integrity sha1-qiT/uaw9+aI1GDfPstJ5NgzXhJI= + +decamelize@^1.1.2, decamelize@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= -decamelize@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-2.0.0.tgz#656d7bbc8094c4c788ea53c5840908c9c7d063c7" - integrity sha512-Ikpp5scV3MSYxY39ymh45ZLEecsTdv/Xj2CaQfI8RLMuwi7XvjX9H/fhraiSuU+C5w5NTDu4ZU72xNiZnurBPg== - dependencies: - xregexp "4.0.0" - decode-uri-component@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" @@ -3609,11 +4160,6 @@ deep-extend@^0.6.0: resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== -deep-freeze-strict@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/deep-freeze-strict/-/deep-freeze-strict-1.1.1.tgz#77d0583ca24a69be4bbd9ac2fae415d55523e5b0" - integrity sha1-d9BYPKJKab5LvZrC+uQV1VUj5bA= - deep-freeze@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/deep-freeze/-/deep-freeze-0.0.1.tgz#3a0b0005de18672819dfd38cd31f91179c893e84" @@ -3624,14 +4170,6 @@ deep-is@~0.1.3: resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= -default-gateway@^2.6.0: - version "2.7.2" - resolved "https://registry.yarnpkg.com/default-gateway/-/default-gateway-2.7.2.tgz#b7ef339e5e024b045467af403d50348db4642d0f" - integrity sha512-lAc4i9QJR0YHSDFdzeBQKfZ1SRDG3hsJNEkrpcZa8QhBfidLAilT60BDEIVUUGqosFp425KOgB3uYqcnQrWafQ== - dependencies: - execa "^0.10.0" - ip-regex "^2.1.0" - default-gateway@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/default-gateway/-/default-gateway-4.2.0.tgz#167104c7500c2115f6dd69b0a536bb8ed720552b" @@ -3682,18 +4220,6 @@ del@^2.2.0: pinkie-promise "^2.0.0" rimraf "^2.2.8" -del@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/del/-/del-3.0.0.tgz#53ecf699ffcbcb39637691ab13baf160819766e5" - integrity sha1-U+z2mf/LyzljdpGrE7rxYIGXZuU= - dependencies: - globby "^6.1.0" - is-path-cwd "^1.0.0" - is-path-in-cwd "^1.0.0" - p-map "^1.1.1" - pify "^3.0.0" - rimraf "^2.2.8" - del@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/del/-/del-4.1.1.tgz#9e8f117222ea44a31ff3a156c049b99052a9f0b4" @@ -3712,11 +4238,6 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= -delegates@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" - integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= - depd@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.1.tgz#5783b4e1c459f06fa5ca27f991f3d06e7a310359" @@ -3762,16 +4283,19 @@ detect-indent@^4.0.0: dependencies: repeating "^2.0.0" -detect-libc@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" - integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= - detect-node@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.4.tgz#014ee8f8f669c5c58023da64b8179c083a28c46c" integrity sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw== +dezalgo@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.3.tgz#7f742de066fc748bc8db820569dddce49bf0d456" + integrity sha1-f3Qt4Gb8dIvI24IFad3c5Jvw1FY= + dependencies: + asap "^2.0.0" + wrappy "1" + di@^0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/di/-/di-0.0.1.tgz#806649326ceaa7caa3306d75d985ea2748ba913c" @@ -3873,7 +4397,7 @@ domhandler@2.1: dependencies: domelementtype "1" -domino@^2.1.0: +domino@^2.1.2: version "2.1.4" resolved "https://registry.yarnpkg.com/domino/-/domino-2.1.4.tgz#78922e7fab7c610f35792b6c745b7962d342e9c4" integrity sha512-l70mlQ7IjPKC8kT7GljQXJZmt5OqFL+RE91ik5y5WWQtsd9wP8R7gpFnNu96fK5MqAAZRXfLLsnzKtkty5fWGQ== @@ -3973,10 +4497,10 @@ electron-to-chromium@^1.3.30, electron-to-chromium@^1.3.61: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.62.tgz#2e8e2dc070c800ec8ce23ff9dfcceb585d6f9ed8" integrity sha512-x09ndL/Gjnuk3unlAyoGyUg3wbs4w/bXurgL7wL913vXHAOWmMhrLf1VNGRaMLngmadd5Q8gsV9BFuIr6rP+Xg== -electron-to-chromium@^1.3.322: - version "1.3.322" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.322.tgz#a6f7e1c79025c2b05838e8e344f6e89eb83213a8" - integrity sha512-Tc8JQEfGQ1MzfSzI/bTlSr7btJv/FFO7Yh6tanqVmIWOuNCu6/D1MilIEgLtmWqIrsv+o4IjpLAhgMBr/ncNAA== +electron-to-chromium@^1.3.341: + version "1.3.345" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.345.tgz#2569d0d54a64ef0f32a4b7e8c80afa5fe57c5d98" + integrity sha512-f8nx53+Z9Y+SPWGg3YdHrbYYfIJAtbUjpFfW4X1RwTZ94iUG7geg9tV8HqzAXX7XTNgyWgAFvce4yce8ZKxKmg== elliptic@^6.0.0: version "6.4.1" @@ -4221,14 +4745,6 @@ escodegen@1.8.x: optionalDependencies: source-map "~0.2.0" -eslint-scope@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.0.tgz#50bf3071e9338bcdc43331794a0cb533f0136172" - integrity sha512-1G6UTDi7Jc1ELFwnR58HV4fK9OQK4S6N985f166xqXxpjU6plxFISJa2Ba9KCQuFa8RCnj/lSFJbHo7UFDBnUA== - dependencies: - esrecurse "^4.1.0" - estraverse "^4.1.1" - eslint-scope@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.3.tgz#ca03833310f6889a3264781aa82e63eb9cfe7848" @@ -4381,16 +4897,6 @@ eventemitter3@^3.0.0: resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.0.tgz#090b4d6cdbd645ed10bf750d4b5407942d7ba163" integrity sha512-ivIvhpq/Y0uSjcHDcOIccjmYjGLcP09MFGE7ysAwkAvkXfpZlC985pH2/ui64DKazbTW/4kN3yqozUxlXzI6cA== -eventemitter3@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.0.tgz#d65176163887ee59f386d64c82610b696a4a74eb" - integrity sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg== - -events@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924" - integrity sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ= - events@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/events/-/events-3.0.0.tgz#9a0a0dfaf62893d92b875b8f2698ca4114973e88" @@ -4548,7 +5054,7 @@ express@4.16.2: utils-merge "1.0.1" vary "~1.1.2" -express@^4.16.2, express@^4.16.3, express@^4.17.1: +express@^4.16.3, express@^4.17.1: version "4.17.1" resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134" integrity sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g== @@ -4627,15 +5133,6 @@ external-editor@^2.0.1: iconv-lite "^0.4.17" tmp "^0.0.33" -external-editor@^3.0.0: - version "3.0.3" - resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.0.3.tgz#5866db29a97826dbe4bf3afd24070ead9ea43a27" - integrity sha512-bn71H9+qWoOQKyZDo25mOMVpSmXROAsTJVVVYzrrtol3d4y+AsKjf4Iwl2Q+IuT0kFSQ1qo166UuIwqYq7mGnA== - dependencies: - chardet "^0.7.0" - iconv-lite "^0.4.24" - tmp "^0.0.33" - external-editor@^3.0.3: version "3.1.0" resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495" @@ -4801,19 +5298,24 @@ file-entry-cache@^5.0.1: dependencies: flat-cache "^2.0.1" -file-loader@3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-3.0.1.tgz#f8e0ba0b599918b51adfe45d66d1e771ad560faa" - integrity sha512-4sNIOXgtH/9WZq4NvlfU3Opn5ynUsqBwSLyM+I7UOwdGigTBYfVVQEwe/msZNX/j4pCJTIM14Fsw66Svo1oVrw== +file-loader@4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-4.2.0.tgz#5fb124d2369d7075d70a9a5abecd12e60a95215e" + integrity sha512-+xZnaK5R8kBJrHK0/6HRlrKNamvVS5rjyuju+rnyxRGuwUJwpAMsVzUl5dz6rK8brkzjV6JpcFNjp6NqV0g1OQ== dependencies: - loader-utils "^1.0.2" - schema-utils "^1.0.0" + loader-utils "^1.2.3" + schema-utils "^2.0.0" file-saver@^1.3.8: version "1.3.8" resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-1.3.8.tgz#e68a30c7cb044e2fb362b428469feb291c2e09d8" integrity sha512-spKHSBQIxxS81N/O21WmuXA2F6wppUCsutpzenOeZzOCCJ5gEfcbqJP983IrpLXzYmXnMUa6J03SubcNPdKrlg== +file-uri-to-path@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" + integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== + filename-regex@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26" @@ -4845,6 +5347,13 @@ fill-range@^4.0.0: repeat-string "^1.6.1" to-regex-range "^2.1.0" +fill-range@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" + integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== + dependencies: + to-regex-range "^5.0.1" + finalhandler@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.0.tgz#ce0b6855b45853e791b2fcc680046d88253dd7f5" @@ -4871,16 +5380,16 @@ finalhandler@~1.1.2: statuses "~1.5.0" unpipe "~1.0.0" -find-cache-dir@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-1.0.0.tgz#9288e3e9e3cc3748717d39eade17cf71fc30ee6f" - integrity sha1-kojj6ePMN0hxfTnq3hfPcfww7m8= +find-cache-dir@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.0.0.tgz#cd4b7dd97b7185b7e17dbfe2d6e4115ee3eeb8fc" + integrity sha512-t7ulV1fmbxh5G9l/492O1p5+EBbr3uwpt6odhFTMc+nWyhmbloe+ja9BZ8pIBtqFWhOmCWVjx+pTW4zDkFoclw== dependencies: commondir "^1.0.1" - make-dir "^1.0.0" - pkg-dir "^2.0.0" + make-dir "^3.0.0" + pkg-dir "^4.1.0" -find-cache-dir@^2.0.0, find-cache-dir@^2.1.0: +find-cache-dir@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-2.1.0.tgz#8d0f94cd13fe43c6c7c261a0d86115ca918c05f7" integrity sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ== @@ -4906,13 +5415,6 @@ find-up@^1.0.0: path-exists "^2.0.0" pinkie-promise "^2.0.0" -find-up@^2.0.0, find-up@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" - integrity sha1-RdG35QbHF93UgndaK3eSCjwMV6c= - dependencies: - locate-path "^2.0.0" - find-up@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" @@ -4977,11 +5479,6 @@ font-awesome@4.7.0: resolved "https://registry.yarnpkg.com/font-awesome/-/font-awesome-4.7.0.tgz#8fa8cf0411a1a31afd07b06d2902bb9fc815a133" integrity sha1-j6jPBBGhoxr9B7BtKQK7n8gVoTM= -for-in@^0.1.3: - version "0.1.8" - resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.8.tgz#d8773908e31256109952b1fdb9b3fa867d2775e1" - integrity sha1-2Hc5COMSVhCZUrH9ubP6hn0ndeE= - for-in@^1.0.1, for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" @@ -4994,13 +5491,6 @@ for-own@^0.1.4: dependencies: for-in "^1.0.1" -for-own@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/for-own/-/for-own-1.0.0.tgz#c63332f415cedc4b04dbfe70cf836494c53cb44b" - integrity sha1-xjMy9BXO3EsE2/5wz4NklMU8tEs= - dependencies: - for-in "^1.0.1" - forever-agent@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" @@ -5147,31 +5637,18 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= -fsevents@^1.2.2: - version "1.2.4" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.4.tgz#f41dcb1af2582af3692da36fc55cbd8e1041c426" - integrity sha512-z8H8/diyk76B7q5wg+Ud0+CqzcAF3mBBI/bA5ne5zrRUUIvNkJY//D3BqyH571KuAC4Nr7Rw7CjWX4r0y9DvNg== - dependencies: - nan "^2.9.2" - node-pre-gyp "^0.10.0" - -fsevents@^1.2.7: - version "1.2.9" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.9.tgz#3f5ed66583ccd6f400b5a00db6f7e861363e388f" - integrity sha512-oeyj2H3EjjonWcFjD5NvZNE9Rqe4UW+nQBU2HNeKw0koVLEFIhtyETyAakeAM3de7Z/SW5kcA+fZUait9EApnw== +fsevents@^1.2.2, fsevents@^1.2.7: + version "1.2.11" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.11.tgz#67bf57f4758f02ede88fb2a1712fef4d15358be3" + integrity sha512-+ux3lx6peh0BpvY0JebGyZoiR4D+oYzdPZMKJwkZ+sFkNJzpL7tXc/wehS49gUAxg3tmMHPHZkA8JU2rhhgDHw== dependencies: + bindings "^1.5.0" nan "^2.12.1" - node-pre-gyp "^0.12.0" -fstream@^1.0.0, fstream@^1.0.2: - version "1.0.12" - resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.12.tgz#4e8ba8ee2d48be4f7d0de505455548eae5932045" - integrity sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg== - dependencies: - graceful-fs "^4.1.2" - inherits "~2.0.0" - mkdirp ">=0.5 0" - rimraf "2" +fsevents@~2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.2.tgz#4c0a1fb34bc68e543b4b82a9ec392bfbda840805" + integrity sha512-R4wDiBwZ0KzpgOWetKDug1FZcYhqYnUYKtfZYt4mD5SBz76q0KR4Q9o7GIPamsVPGmW3EYPPJ0dOOjvx32ldZA== function-bind@^1.0.2, function-bind@^1.1.1: version "1.1.1" @@ -5183,32 +5660,16 @@ functional-red-black-tree@^1.0.1: resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= -gauge@~2.7.3: - version "2.7.4" - resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" - integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c= - dependencies: - aproba "^1.0.3" - console-control-strings "^1.0.0" - has-unicode "^2.0.0" - object-assign "^4.1.0" - signal-exit "^3.0.0" - string-width "^1.0.1" - strip-ansi "^3.0.1" - wide-align "^1.1.0" - -gaze@^1.0.0: - version "1.1.3" - resolved "https://registry.yarnpkg.com/gaze/-/gaze-1.1.3.tgz#c441733e13b927ac8c0ff0b4c3b033f28812924a" - integrity sha512-BRdNm8hbWzFzWHERTrejLqwHDfS4GibPoq5wjTPIoJHoBtKGPg3xAFfxmM+9ztbXelxcf2hwQcaz1PtmFeue8g== - dependencies: - globule "^1.0.0" - genfun@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/genfun/-/genfun-5.0.0.tgz#9dd9710a06900a5c4a5bf57aca5da4e52fe76537" integrity sha512-KGDOARWVga7+rnB3z9Sd2Letx515owfk0hSxHGuqjANb1M+x2bGZGqHLiozPsYMdM2OubeMni/Hpwmjq6qIUhA== +gensync@^1.0.0-beta.1: + version "1.0.0-beta.1" + resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.1.tgz#58f4361ff987e5ff6e1e7a210827aa371eaac269" + integrity sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg== + get-caller-file@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a" @@ -5276,7 +5737,7 @@ glob-parent@^3.1.0: is-glob "^3.1.0" path-dirname "^1.0.0" -glob-parent@^5.0.0: +glob-parent@^5.0.0, glob-parent@~5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.0.tgz#5f4c1d1e748d30cd73ad2944b3577a81b081e8c2" integrity sha512-qjtRgnIVmOfnKUE3NJAQEdk+lKrxfw8t5ke7SXtfMTHcjsBfOfWXCQfdb30zfDoZQ2IRSIiidmjtbHZPZ++Ihw== @@ -5312,10 +5773,10 @@ glob@7.1.2: once "^1.3.0" path-is-absolute "^1.0.0" -glob@7.1.3, glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.0.6, glob@^7.1.1, glob@^7.1.2, glob@~7.1.1: - version "7.1.3" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.3.tgz#3960832d3f1574108342dafd3a67b332c0969df1" - integrity sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ== +glob@7.1.4, glob@^7.1.3, glob@^7.1.4: + version "7.1.4" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.4.tgz#aa608a2f6c577ad357e1ae5a5c26d9a8d1969255" + integrity sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A== dependencies: fs.realpath "^1.0.0" inflight "^1.0.4" @@ -5335,10 +5796,10 @@ glob@^5.0.15: once "^1.3.0" path-is-absolute "^1.0.0" -glob@^7.1.3, glob@^7.1.4: - version "7.1.4" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.4.tgz#aa608a2f6c577ad357e1ae5a5c26d9a8d1969255" - integrity sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A== +glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.0.6, glob@^7.1.1, glob@^7.1.2: + version "7.1.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.3.tgz#3960832d3f1574108342dafd3a67b332c0969df1" + integrity sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ== dependencies: fs.realpath "^1.0.0" inflight "^1.0.4" @@ -5402,6 +5863,11 @@ global-prefix@^3.0.0: kind-of "^6.0.2" which "^1.3.1" +globals@^11.1.0: + version "11.12.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" + integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== + globals@^12.1.0: version "12.3.0" resolved "https://registry.yarnpkg.com/globals/-/globals-12.3.0.tgz#1e564ee5c4dded2ab098b0f88f24702a3c56be13" @@ -5462,15 +5928,6 @@ globby@^8.0.0: pify "^3.0.0" slash "^1.0.0" -globule@^1.0.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/globule/-/globule-1.2.1.tgz#5dffb1b191f22d20797a9369b49eab4e9839696d" - integrity sha512-g7QtgWF4uYSL5/dn71WxubOrS7JVGCnFPEnoeChJmBnyR9Mw8nGoEwOgJL/RC2Te0WhbsEUCejfH8SZNJ+adYQ== - dependencies: - glob "~7.1.1" - lodash "~4.17.10" - minimatch "~3.0.2" - glogg@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/glogg/-/glogg-1.0.1.tgz#dcf758e44789cc3f3d32c1f3562a3676e6a34810" @@ -5649,11 +6106,6 @@ has-symbols@^1.0.0: resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.0.tgz#ba1a8f1af2a0fc39650f5c850367704122063b44" integrity sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q= -has-unicode@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" - integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk= - has-value@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" @@ -5792,7 +6244,7 @@ html-comment-regex@^1.1.0: resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.2.tgz#97d4688aeb5c81886a364faa0cad1dda14d433a7" integrity sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ== -html-entities@^1.2.0, html-entities@^1.2.1: +html-entities@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-1.2.1.tgz#0df29351f0721163515dfb9e5543e5f6eed5162f" integrity sha1-DfKTUfByEWNRXfueVUPl9u7VFi8= @@ -5887,7 +6339,7 @@ http-proxy-agent@^2.1.0: agent-base "4" debug "3.1.0" -http-proxy-middleware@^0.19.1: +http-proxy-middleware@0.19.1, http-proxy-middleware@^0.19.1: version "0.19.1" resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-0.19.1.tgz#183c7dc4aa1479150306498c210cdaf96080a43a" integrity sha512-yHYTgWMQO8VvwNS22eLLloAkvungsKdKTLO8AJlftYIKNfJr3GK3zK0ZCfzDDGUBttdGc8xFy1mCitvNKQtC3Q== @@ -5897,16 +6349,6 @@ http-proxy-middleware@^0.19.1: lodash "^4.17.11" micromatch "^3.1.10" -http-proxy-middleware@~0.18.0: - version "0.18.0" - resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-0.18.0.tgz#0987e6bb5a5606e5a69168d8f967a87f15dd8aab" - integrity sha512-Fs25KVMPAIIcgjMZkVHJoKg9VcXcC1C8yb9JUgeDvVXY0S/zgVIhMb+qVswDIgtJe2DfckMSY2d6TuTEutlk6Q== - dependencies: - http-proxy "^1.16.2" - is-glob "^4.0.0" - lodash "^4.17.5" - micromatch "^3.1.9" - http-proxy@^1.13.0, http-proxy@^1.17.0, http-proxy@^1.8.1: version "1.17.0" resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.17.0.tgz#7ad38494658f84605e2f6db4436df410f4e5be9a" @@ -5916,15 +6358,6 @@ http-proxy@^1.13.0, http-proxy@^1.17.0, http-proxy@^1.8.1: follow-redirects "^1.0.0" requires-port "^1.0.0" -http-proxy@^1.16.2: - version "1.18.0" - resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.0.tgz#dbe55f63e75a347db7f3d99974f2692a314a6a3a" - integrity sha512-84I2iJM/n1d4Hdgc6y2+qY5mDaz2PUVjlg9znE9byl+q0uC3DeByqBGReQu5tpLK0TAqTIXScRUV+dg7+bUPpQ== - dependencies: - eventemitter3 "^4.0.0" - follow-redirects "^1.0.0" - requires-port "^1.0.0" - http-server@0.11.1: version "0.11.1" resolved "https://registry.yarnpkg.com/http-server/-/http-server-0.11.1.tgz#2302a56a6ffef7f9abea0147d838a5e9b6b6a79b" @@ -5961,6 +6394,14 @@ https-proxy-agent@^2.2.1: agent-base "^4.1.0" debug "^3.1.0" +https-proxy-agent@^2.2.3: + version "2.2.4" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.4.tgz#4ee7a737abd92678a293d9b34a1af4d0d08c787b" + integrity sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg== + dependencies: + agent-base "^4.3.0" + debug "^3.1.0" + https@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/https/-/https-1.0.0.tgz#3c37c7ae1a8eeb966904a2ad1e975a194b7ed3a4" @@ -5985,7 +6426,7 @@ iconv-lite@0.4.23: dependencies: safer-buffer ">= 2.1.2 < 3" -iconv-lite@0.4.24, iconv-lite@^0.4.17, iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@~0.4.13: +iconv-lite@0.4.24, iconv-lite@^0.4.17, iconv-lite@^0.4.24, iconv-lite@~0.4.13: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== @@ -6097,11 +6538,6 @@ imurmurhash@^0.1.4: resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= -in-publish@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/in-publish/-/in-publish-2.0.0.tgz#e20ff5e3a2afc2690320b6dc552682a9c7fadf51" - integrity sha1-4g/146KvwmkDILbcVSaCqcf631E= - indent-string@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80" @@ -6137,7 +6573,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.3, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3: +inherits@2, inherits@2.0.3, inherits@~2.0.1, inherits@~2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= @@ -6176,23 +6612,23 @@ inquirer@3.0.6: strip-ansi "^3.0.0" through "^2.3.6" -inquirer@6.2.1: - version "6.2.1" - resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.2.1.tgz#9943fc4882161bdb0b0c9276769c75b32dbfcd52" - integrity sha512-088kl3DRT2dLU5riVMKKr1DlImd6X7smDhpXUCkJDCKvTEJeRiXh0G132HG9u5a+6Ylw9plFRY7RuTnwohYSpg== +inquirer@6.5.1: + version "6.5.1" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.5.1.tgz#8bfb7a5ac02dac6ff641ac4c5ff17da112fcdb42" + integrity sha512-uxNHBeQhRXIoHWTSNYUFhQVrHYFThIt6IVo2fFmSe8aBwdR3/w6b58hJpiL/fMukFkvGzjg+hSxFtwvVmKZmXw== dependencies: - ansi-escapes "^3.0.0" - chalk "^2.0.0" - cli-cursor "^2.1.0" + ansi-escapes "^4.2.1" + chalk "^2.4.2" + cli-cursor "^3.1.0" cli-width "^2.0.0" - external-editor "^3.0.0" - figures "^2.0.0" - lodash "^4.17.10" - mute-stream "0.0.7" + external-editor "^3.0.3" + figures "^3.0.0" + lodash "^4.17.15" + mute-stream "0.0.8" run-async "^2.2.0" - rxjs "^6.1.0" - string-width "^2.1.0" - strip-ansi "^5.0.0" + rxjs "^6.4.0" + string-width "^4.1.0" + strip-ansi "^5.1.0" through "^2.3.6" inquirer@^7.0.0: @@ -6214,14 +6650,6 @@ inquirer@^7.0.0: strip-ansi "^5.1.0" through "^2.3.6" -internal-ip@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-3.0.1.tgz#df5c99876e1d2eb2ea2d74f520e3f669a00ece27" - integrity sha512-NXXgESC2nNVtU+pqmC9e6R8B1GpKxzsAQhffvh5AL79qKnodd+L7tnEQmTiUAVngqLalPbSqRA7XGIEL5nCd0Q== - dependencies: - default-gateway "^2.6.0" - ipaddr.js "^1.5.2" - internal-ip@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-4.3.0.tgz#845452baad9d2ca3b69c635a137acb9a0dad0907" @@ -6240,18 +6668,13 @@ interpret@^1.0.0: resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.1.0.tgz#7ed1b1410c6a0e0f78cf95d3b8440c63f78b8614" integrity sha1-ftGxQQxqDg94z5XTuEQMY/eLhhQ= -invariant@^2.2.2: +invariant@^2.2.2, invariant@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== dependencies: loose-envify "^1.0.0" -invert-kv@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" - integrity sha1-EEqOSqym09jNFXqO+L+rLXo//bY= - invert-kv@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-2.0.0.tgz#7393f5afa59ec9ff5f67a27620d11c226e3eec02" @@ -6277,7 +6700,7 @@ ipaddr.js@1.9.0: resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.0.tgz#37df74e430a0e47550fe54a2defe30d8acd95f65" integrity sha512-M4Sjn6N/+O6/IXSJseKqHoFc+5FdGJ22sXqnjTpdZweHK64MzEPAyQZyEU3R/KRv2GLoa7nNtg/C2Ev6m7z+eA== -ipaddr.js@^1.5.2, ipaddr.js@^1.9.0: +ipaddr.js@^1.9.0: version "1.9.1" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== @@ -6287,6 +6710,11 @@ is-absolute-url@^2.0.0: resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6" integrity sha1-UFMN+4T8yap9vnhS6Do3uTufKqY= +is-absolute-url@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-3.0.3.tgz#96c6a22b6a23929b11ea0afb1836c36ad4a5d698" + integrity sha512-opmNIX7uFnS96NtPmhWQgQx6/NYFgsUXYMllcfzwWKUMwfo8kku1TvE6hkNcH+Q1ts5cMVrsY7j0bxXQDciu9Q== + is-accessor-descriptor@^0.1.6: version "0.1.6" resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" @@ -6318,6 +6746,13 @@ is-binary-path@^1.0.0: dependencies: binary-extensions "^1.0.0" +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + is-buffer@^1.1.5, is-buffer@~1.1.1: version "1.1.6" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" @@ -6475,7 +6910,7 @@ is-glob@^4.0.0: dependencies: is-extglob "^2.1.1" -is-glob@^4.0.1: +is-glob@^4.0.1, is-glob@~4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== @@ -6519,6 +6954,11 @@ is-number@^4.0.0: resolved "https://registry.yarnpkg.com/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff" integrity sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ== +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + is-obj@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" @@ -6562,6 +7002,11 @@ is-path-inside@^2.1.0: dependencies: path-is-inside "^1.0.2" +is-plain-obj@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e" + integrity sha1-caUMhCnfync8kqOQpKA7OfzVHT4= + is-plain-object@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" @@ -6714,6 +7159,11 @@ istanbul-lib-coverage@^1.2.0: resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-1.2.0.tgz#f7d8f2e42b97e37fe796114cb0f9d68b5e3a4341" integrity sha512-GvgM/uXRwm+gLlvkWHTjDAvwynZkL9ns15calTrmhGgowlwJBbWMYzWbKqE2DT6JDP1AFXKa+Zi0EkqNCUqY0A== +istanbul-lib-coverage@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.0.0.tgz#f5944a37c70b550b02a78a5c3b2055b280cec8ec" + integrity sha512-UiUIqxMgRDET6eR+o5HbfRYP1l0hqkWOs7vNxC/mggutCMUIhWMm8gAHb8tHlyfD3/l6rlgNA5cKdDzEAf6hEg== + istanbul-lib-instrument@^1.7.3: version "1.10.1" resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-1.10.1.tgz#724b4b6caceba8692d3f1f9d0727e279c401af7b" @@ -6727,6 +7177,19 @@ istanbul-lib-instrument@^1.7.3: istanbul-lib-coverage "^1.2.0" semver "^5.3.0" +istanbul-lib-instrument@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.1.tgz#61f13ac2c96cfefb076fe7131156cc05907874e6" + integrity sha512-imIchxnodll7pvQBYOqUu88EufLCU56LMeFPZZM/fJZ1irYcYdqroaV+ACK1Ila8ls09iEYArp+nqyC6lW1Vfg== + dependencies: + "@babel/core" "^7.7.5" + "@babel/parser" "^7.7.5" + "@babel/template" "^7.7.4" + "@babel/traverse" "^7.7.4" + "@istanbuljs/schema" "^0.1.2" + istanbul-lib-coverage "^3.0.0" + semver "^6.3.0" + istanbul@0.4.5, istanbul@^0.4.0, istanbul@^0.4.3: version "0.4.5" resolved "https://registry.yarnpkg.com/istanbul/-/istanbul-0.4.5.tgz#65c7d73d4c4da84d4f3ac310b918fb0b8033733b" @@ -6785,14 +7248,7 @@ jasminewd2@^2.1.0: resolved "https://registry.yarnpkg.com/jasminewd2/-/jasminewd2-2.2.0.tgz#e37cf0b17f199cce23bea71b2039395246b4ec4e" integrity sha1-43zwsX8ZnM4jvqcbIDk5Uka07E4= -jest-worker@^23.2.0: - version "23.2.0" - resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-23.2.0.tgz#faf706a8da36fae60eb26957257fa7b5d8ea02b9" - integrity sha1-+vcGqNo2+uYOsmlXJX+ntdjqArk= - dependencies: - merge-stream "^1.0.1" - -jest-worker@^24.9.0: +jest-worker@24.9.0, jest-worker@^24.9.0: version "24.9.0" resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-24.9.0.tgz#5dbfdb5b2d322e98567898238a9697bcce67b3e5" integrity sha512-51PE4haMSXcHohnSMdM42anbvZANYTqMrr52tVKPqqsPJMzoP6FYYDVqahX/HrAoKEKz3uUPzSvKs9A3qR4iVw== @@ -6800,10 +7256,12 @@ jest-worker@^24.9.0: merge-stream "^2.0.0" supports-color "^6.1.0" -js-base64@^2.1.8: - version "2.4.9" - resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.4.9.tgz#748911fb04f48a60c4771b375cac45a80df11c03" - integrity sha512-xcinL3AuDJk7VSzsHgb9DvvIXayBbadtMZ4HFPx8rUszbW1MuNMlwYVC4zzCZ6e1sqZpnNS5ZFYOhXqA39T7LQ== +jest-worker@^23.2.0: + version "23.2.0" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-23.2.0.tgz#faf706a8da36fae60eb26957257fa7b5d8ea02b9" + integrity sha1-+vcGqNo2+uYOsmlXJX+ntdjqArk= + dependencies: + merge-stream "^1.0.1" js-cookie@2.2.0: version "2.2.0" @@ -6843,6 +7301,11 @@ jsesc@^1.3.0: resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-1.3.0.tgz#46c3fec8c1892b12b0833db9bc7622176dbab34b" integrity sha1-RsP+yMGJKxKwgz25vHYiF226s0s= +jsesc@^2.5.1: + version "2.5.2" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" + integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== + jsesc@~0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" @@ -7029,10 +7492,10 @@ karma-remap-istanbul@0.6.0: istanbul "^0.4.3" remap-istanbul "^0.9.0" -karma-source-map-support@1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/karma-source-map-support/-/karma-source-map-support-1.3.0.tgz#36dd4d8ca154b62ace95696236fae37caf0a7dde" - integrity sha512-HcPqdAusNez/ywa+biN4EphGz62MmQyPggUsDfsHqa7tSe4jdsxgvTKuDfIazjL+IOxpVWyT7Pr4dhAV+sxX5Q== +karma-source-map-support@1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz#58526ceccf7e8730e56effd97a4de8d712ac0d6b" + integrity sha512-RsBECncGO17KAoJCYXjv+ckIz+Ii9NCi+9enk+rq6XC81ezYkb4/RHE6CTXdA7IOJqoF3wcaLfVG0CPmE5ca6A== dependencies: source-map-support "^0.5.5" @@ -7100,7 +7563,7 @@ kew@^0.7.0: resolved "https://registry.yarnpkg.com/kew/-/kew-0.7.0.tgz#79d93d2d33363d6fdd2970b335d9141ad591d79b" integrity sha1-edk9LTM2PW/dKXCzNdkUGtWR15s= -killable@^1.0.0, killable@^1.0.1: +killable@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/killable/-/killable-1.0.1.tgz#4c8ce441187a061c7474fb87ca08e2a638194892" integrity sha512-LzqtLKlUwirEUyl/nicirVmNiPvYs7l5n8wOPP7fyJVpUPkvCnW/vuiXGpylGUlnPDnB7311rARzAt3Mhswpjg== @@ -7163,13 +7626,6 @@ lazystream@^1.0.0: dependencies: readable-stream "^2.0.5" -lcid@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835" - integrity sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU= - dependencies: - invert-kv "^1.0.0" - lcid@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/lcid/-/lcid-2.0.0.tgz#6ef5d2df60e52f82eb228a4c373e8d1f397253cf" @@ -7182,14 +7638,14 @@ lcov-parse@^0.0.10: resolved "https://registry.yarnpkg.com/lcov-parse/-/lcov-parse-0.0.10.tgz#1b0b8ff9ac9c7889250582b70b71315d9da6d9a3" integrity sha1-GwuP+ayceIklBYK3C3ExXZ2m2aM= -less-loader@4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/less-loader/-/less-loader-4.1.0.tgz#2c1352c5b09a4f84101490274fd51674de41363e" - integrity sha512-KNTsgCE9tMOM70+ddxp9yyt9iHqgmSs0yTZc5XH5Wo+g80RWRIYNqE58QJKm/yMud5wZEvz50ugRDuzVIkyahg== +less-loader@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/less-loader/-/less-loader-5.0.0.tgz#498dde3a6c6c4f887458ee9ed3f086a12ad1b466" + integrity sha512-bquCU89mO/yWLaUq0Clk7qCsKhsF/TZpJUzETRvJa9KSVEL9SO3ovCvdEHISBhrC81OwC8QSVX7E0bzElZj9cg== dependencies: clone "^2.1.1" loader-utils "^1.1.0" - pify "^3.0.0" + pify "^4.0.1" less@3.9.0: version "3.9.0" @@ -7207,6 +7663,18 @@ less@3.9.0: request "^2.83.0" source-map "~0.6.0" +leven@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" + integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== + +levenary@^1.1.0, levenary@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/levenary/-/levenary-1.1.1.tgz#842a9ee98d2075aa7faeedbe32679e9205f46f77" + integrity sha512-mkAdOIt79FD6irqjYSs4rdbnlT5vRonMEvBVPVb3XmevfS8kgRXwfes0dhPdEtzTWD/1eNE/Bm/G1iRt6DcnQQ== + dependencies: + leven "^3.1.0" + levn@^0.3.0, levn@~0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" @@ -7215,10 +7683,10 @@ levn@^0.3.0, levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" -license-webpack-plugin@2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/license-webpack-plugin/-/license-webpack-plugin-2.1.0.tgz#83acaa6e89c3c5316effdd80cb4ec9c5cd8efc2f" - integrity sha512-vDiBeMWxjE9n6TabQ9J4FH8urFdsRK0Nvxn1cit9biCiR9aq1zBR0X2BlAkEiIG6qPamLeU0GzvIgLkrFc398A== +license-webpack-plugin@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/license-webpack-plugin/-/license-webpack-plugin-2.1.2.tgz#63f7c571537a450ec47dc98f5d5ffdbca7b3b14f" + integrity sha512-7poZHRla+ae0eEButlwMrPpkXyhNVBf2EHePYWT0jyLnI6311/OXJkTI2sOIRungRpQgU2oDMpro5bSFPT5F0A== dependencies: "@types/webpack-sources" "^0.1.5" webpack-sources "^1.2.0" @@ -7241,16 +7709,6 @@ load-json-file@^1.0.0: pinkie-promise "^2.0.0" strip-bom "^2.0.0" -load-json-file@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-2.0.0.tgz#7947e42149af80d696cbf797bcaabcfe1fe29ca8" - integrity sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg= - dependencies: - graceful-fs "^4.1.2" - parse-json "^2.2.0" - pify "^2.0.0" - strip-bom "^3.0.0" - load-json-file@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-4.0.0.tgz#2f5f45ab91e33216234fd53adab668eb4ec0993b" @@ -7261,11 +7719,6 @@ load-json-file@^4.0.0: pify "^3.0.0" strip-bom "^3.0.0" -loader-runner@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.3.0.tgz#f482aea82d543e07921700d5a46ef26fdac6b8a2" - integrity sha1-9IKuqC1UPgeSFwDVpG7yb9rGuKI= - loader-runner@^2.4.0: version "2.4.0" resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.4.0.tgz#ed47066bfe534d7e84c4c7b9998c2a75607d9357" @@ -7299,14 +7752,6 @@ loader-utils@^1.0.0: emojis-list "^2.0.0" json5 "^0.5.0" -locate-path@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" - integrity sha1-K1aLJl7slExtnA3pw9u7ygNUzY4= - dependencies: - p-locate "^2.0.0" - path-exists "^3.0.0" - locate-path@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" @@ -7367,12 +7812,7 @@ lodash._root@^3.0.0: resolved "https://registry.yarnpkg.com/lodash._root/-/lodash._root-3.0.1.tgz#fba1c4524c19ee9a5f8136b4609f017cf4ded692" integrity sha1-+6HEUkwZ7ppfgTa0YJ8BfPTe1pI= -lodash.assign@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/lodash.assign/-/lodash.assign-4.2.0.tgz#0d99f3ccd7a6d261d19bdaeb9245005d285808e7" - integrity sha1-DZnzzNem0mHRm9rrkkUAXShYCOc= - -lodash.clonedeep@^4.3.2, lodash.clonedeep@^4.5.0: +lodash.clonedeep@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= @@ -7448,11 +7888,6 @@ lodash.memoize@^4.1.2: resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= -lodash.mergewith@^4.6.0: - version "4.6.2" - resolved "https://registry.yarnpkg.com/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz#617121f89ac55f59047c7aec1ccd6654c6590f55" - integrity sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ== - lodash.restparam@^3.0.0: version "3.6.1" resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805" @@ -7463,11 +7898,6 @@ lodash.startswith@^4.2.1: resolved "https://registry.yarnpkg.com/lodash.startswith/-/lodash.startswith-4.2.1.tgz#c598c4adce188a27e53145731cdc6c0e7177600c" integrity sha1-xZjErc4YiiflMUVzHNxsDnF3YAw= -lodash.tail@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/lodash.tail/-/lodash.tail-4.1.1.tgz#d2333a36d9e7717c8ad2f7cacafec7c32b444664" - integrity sha1-0jM6NtnncXyK0vfKyv7HwytERmQ= - lodash.template@^3.0.0: version "3.6.2" resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-3.6.2.tgz#f8cdecc6169a255be9098ae8b0c53d378931d14f" @@ -7521,7 +7951,7 @@ lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= -lodash@^4.0.0, lodash@^4.0.1, lodash@^4.13.1, lodash@^4.17.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.3.0, lodash@^4.5.0, lodash@~4.17.10: +lodash@^4.0.0, lodash@^4.0.1, lodash@^4.13.1, lodash@^4.17.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.3.0, lodash@^4.5.0: version "4.17.15" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== @@ -7549,16 +7979,16 @@ log4js@^4.0.0: rfdc "^1.1.4" streamroller "^1.0.6" -loglevel@^1.4.1: - version "1.6.6" - resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.6.tgz#0ee6300cc058db6b3551fa1c4bf73b83bb771312" - integrity sha512-Sgr5lbboAUBo3eXCSPL4/KoVz3ROKquOjcctxmHIt+vol2DrqTQe3SwkKKuYhEiWB5kYa13YyopJ69deJ1irzQ== - loglevel@^1.6.3: version "1.6.3" resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.3.tgz#77f2eb64be55a404c9fd04ad16d57c1d6d6b1280" integrity sha512-LoEDv5pgpvWgPF4kNYuIp0qqSJVWak/dML0RY74xlzMZiT9w77teNAwKYKWBTYjlokMirg+o3jBwp+vlLrcfAA== +loglevel@^1.6.4: + version "1.6.6" + resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.6.6.tgz#0ee6300cc058db6b3551fa1c4bf73b83bb771312" + integrity sha512-Sgr5lbboAUBo3eXCSPL4/KoVz3ROKquOjcctxmHIt+vol2DrqTQe3SwkKKuYhEiWB5kYa13YyopJ69deJ1irzQ== + loglevelnext@^1.0.1: version "1.0.5" resolved "https://registry.yarnpkg.com/loglevelnext/-/loglevelnext-1.0.5.tgz#36fc4f5996d6640f539ff203ba819641680d75a2" @@ -7600,7 +8030,7 @@ lru-cache@4.1.x: pseudomap "^1.0.2" yallist "^2.1.2" -lru-cache@^4.0.1, lru-cache@^4.1.1: +lru-cache@^4.0.1: version "4.1.3" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.3.tgz#a1175cf3496dfc8436c156c334b4955992bce69c" integrity sha512-fFEhvcgzuIoJVUF8fYr5KR0YqxD238zgObTps31YdADwPPAp82a4M8TrckkWyx7ekNlf9aBcVn81cFwwXngrJA== @@ -7615,6 +8045,13 @@ lru-cache@^5.1.1: dependencies: yallist "^3.0.2" +magic-string@0.25.3: + version "0.25.3" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.3.tgz#34b8d2a2c7fec9d9bdf9929a3fd81d271ef35be9" + integrity sha512-6QK0OpF/phMz0Q2AxILkX2mFhi7m+WMwTRg0LQKq/WBB0cDP4rYH3Wp4/d3OTXlrPLVJT/RFqj8tFeAR4nk8AA== + dependencies: + sourcemap-codec "^1.4.4" + magic-string@^0.22.4: version "0.22.5" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.22.5.tgz#8e9cf5afddf44385c1da5bc2a6a0dbd10b03657e" @@ -7656,16 +8093,16 @@ make-error@^1.1.1: resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.4.tgz#19978ed575f9e9545d2ff8c13e33b5d18a67d535" integrity sha512-0Dab5btKVPhibSalc9QGXb559ED7G7iLjFXBaj9Wq8O3vorueR5K5jaE3hkG6ZQINyhA/JgG6Qk4qdFQjsYV6g== -make-fetch-happen@^4.0.1, make-fetch-happen@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-4.0.2.tgz#2d156b11696fb32bffbafe1ac1bc085dd6c78a79" - integrity sha512-YMJrAjHSb/BordlsDEcVcPyTbiJKkzqMf48N8dAJZT9Zjctrkb6Yg4TY9Sq2AwSIQJFn5qBBKVTYt3vP5FMIHA== +make-fetch-happen@^5.0.0: + version "5.0.2" + resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-5.0.2.tgz#aa8387104f2687edca01c8687ee45013d02d19bd" + integrity sha512-07JHC0r1ykIoruKO8ifMXu+xEU8qOXDFETylktdug6vJDACnP+HKevOu3PXyNPzFyTSlz8vrBYlBO1JZRe8Cag== dependencies: agentkeepalive "^3.4.1" - cacache "^11.3.3" + cacache "^12.0.0" http-cache-semantics "^3.8.1" http-proxy-agent "^2.1.0" - https-proxy-agent "^2.2.1" + https-proxy-agent "^2.2.3" lru-cache "^5.1.1" mississippi "^3.0.0" node-fetch-npm "^2.0.2" @@ -7749,13 +8186,6 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= -mem@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/mem/-/mem-1.1.0.tgz#5edd52b485ca1d900fe64895505399a0dfa45f76" - integrity sha1-Xt1StIXKHZAP5kiVUFOZoN+kX3Y= - dependencies: - mimic-fn "^1.0.0" - mem@^4.0.0: version "4.3.0" resolved "https://registry.yarnpkg.com/mem/-/mem-4.3.0.tgz#461af497bc4ae09608cdb2e60eefb69bff744178" @@ -7778,7 +8208,7 @@ memorystream@^0.3.1: resolved "https://registry.yarnpkg.com/memorystream/-/memorystream-0.3.1.tgz#86d7090b30ce455d63fbae12dda51a47ddcaf9b2" integrity sha1-htcJCzDORV1j+64S3aUaR93K+bI= -meow@^3.3.0, meow@^3.7.0: +meow@^3.3.0: version "3.7.0" resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb" integrity sha1-cstmi0JSKCkKu/qFaJJYcwioAfs= @@ -7799,6 +8229,13 @@ merge-descriptors@1.0.1: resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E= +merge-source-map@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/merge-source-map/-/merge-source-map-1.1.0.tgz#2fdde7e6020939f70906a68f2d7ae685e4c8c646" + integrity sha512-Qkcp7P2ygktpMPh2mCQZaf3jhN6D3Z/qVZHSdWvQ+2Ef5HgRAPBO57A77+ENm0CPx2+1Ce/MYKi3ymqdfuqibw== + dependencies: + source-map "^0.6.1" + merge-stream@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-1.0.1.tgz#4041202d508a342ba00174008df0c251b8c135e1" @@ -7845,7 +8282,7 @@ micromatch@^2.3.11: parse-glob "^3.0.4" regex-cache "^0.4.2" -micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4, micromatch@^3.1.8, micromatch@^3.1.9: +micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4: version "3.1.10" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg== @@ -7911,7 +8348,7 @@ mime@^2.1.0, mime@^2.3.1: resolved "https://registry.yarnpkg.com/mime/-/mime-2.3.1.tgz#b1621c54d63b97c47d3cfe7f7215f7d64517c369" integrity sha512-OEUllcVoydBHGN1z84yfQDimn58pZNNNXgZlHXSboxMlFvgI6MXSWpWKpFRra7H1HxpVhHTkrghfRW49k6yjeg== -mime@^2.4.2: +mime@^2.4.2, mime@^2.4.4: version "2.4.4" resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.4.tgz#bd7b91135fc6b01cde3e9bae33d659b63d8857e5" integrity sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA== @@ -7926,12 +8363,13 @@ mimic-fn@^2.0.0, mimic-fn@^2.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== -mini-css-extract-plugin@0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.5.0.tgz#ac0059b02b9692515a637115b0cc9fed3a35c7b0" - integrity sha512-IuaLjruM0vMKhUUT51fQdQzBYTX49dLj8w68ALEAe2A4iYNpIC4eMac67mt3NzycvjOlf07/kYxJDc0RTl1Wqw== +mini-css-extract-plugin@0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.8.0.tgz#81d41ec4fe58c713a96ad7c723cdb2d0bd4d70e1" + integrity sha512-MNpRGbNA52q6U92i0qbVpQNsgk7LExy41MdAlG84FeytfDOtRIf/mCHdEgG8rpTKOaNKiqUnZdlptF469hxqOw== dependencies: loader-utils "^1.1.0" + normalize-url "1.9.1" schema-utils "^1.0.0" webpack-sources "^1.1.0" @@ -7945,7 +8383,7 @@ minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1: resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo= -"minimatch@2 || 3", minimatch@3.0.4, minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4, minimatch@~3.0.2: +"minimatch@2 || 3", minimatch@3.0.4, minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== @@ -7993,7 +8431,7 @@ minipass-pipeline@^1.2.2: dependencies: minipass "^3.0.0" -minipass@^2.2.1, minipass@^2.3.3: +minipass@^2.2.1: version "2.3.4" resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.3.4.tgz#4768d7605ed6194d6d576169b9e12ef71e9d9957" integrity sha512-mlouk1OHlaUE8Odt1drMtG1bAJA4ZA6B/ehysgV0LUIrDHdKgo1KorZq3pK0b/7Z7LJIQ12MNM6aC+Tn6lUZ5w== @@ -8016,13 +8454,6 @@ minipass@^3.0.0, minipass@^3.1.1: dependencies: yallist "^4.0.0" -minizlib@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.1.0.tgz#11e13658ce46bc3a70a267aac58359d1e0c29ceb" - integrity sha512-4T6Ur/GctZ27nHfpt9THOdRZNgyJ9FZchYO1ceg5S8Q3DNLCKYy44nCZzgCJgcvx2UM8czmqak5BCxJMrq37lA== - dependencies: - minipass "^2.2.1" - minizlib@^1.2.1: version "1.3.3" resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.3.3.tgz#2290de96818a34c29551c8a8d301216bd65a861d" @@ -8030,22 +8461,6 @@ minizlib@^1.2.1: dependencies: minipass "^2.9.0" -mississippi@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-2.0.0.tgz#3442a508fafc28500486feea99409676e4ee5a6f" - integrity sha512-zHo8v+otD1J10j/tC+VNoGK9keCuByhKovAvdn74dmxJl9+mWHnx6EMsDN4lgRoMI/eYo2nchAxniIbUPb5onw== - dependencies: - concat-stream "^1.5.0" - duplexify "^3.4.2" - end-of-stream "^1.1.0" - flush-write-stream "^1.0.0" - from2 "^2.1.0" - parallel-transform "^1.1.0" - pump "^2.0.1" - pumpify "^1.3.3" - stream-each "^1.1.0" - through2 "^2.0.0" - mississippi@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-3.0.0.tgz#ea0a3291f97e0b5e8776b363d5f0a12d94c67022" @@ -8070,15 +8485,7 @@ mixin-deep@^1.2.0: for-in "^1.0.2" is-extendable "^1.0.1" -mixin-object@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/mixin-object/-/mixin-object-2.0.1.tgz#4fb949441dab182540f1fe035ba60e1947a5e57e" - integrity sha1-T7lJRB2rGCVA8f4DW6YOGUel5X4= - dependencies: - for-in "^0.1.3" - is-extendable "^0.1.1" - -mkdirp@0.5.1, mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1: +mkdirp@0.5.1, mkdirp@0.5.x, mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.1: version "0.5.1" resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM= @@ -8182,12 +8589,7 @@ mute-stream@0.0.8: resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== -nan@^2.10.0, nan@^2.9.2: - version "2.11.0" - resolved "https://registry.yarnpkg.com/nan/-/nan-2.11.0.tgz#574e360e4d954ab16966ec102c0c049fd961a099" - integrity sha512-F4miItu2rGnV2ySkXOQoA8FKz/SR2Q2sWP0sbTxNxz/tuokeC8WxOhPMcwi0qIyGtVn/rrSeLbvVkznqCdwYnw== - -nan@^2.12.1, nan@^2.13.2: +nan@^2.12.1: version "2.14.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c" integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg== @@ -8219,15 +8621,6 @@ ncp@^2.0.0: resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3" integrity sha1-GVoh1sRuNh0vsSgbo4uR6d9727M= -needle@^2.2.1: - version "2.2.2" - resolved "https://registry.yarnpkg.com/needle/-/needle-2.2.2.tgz#1120ca4c41f2fcc6976fd28a8968afe239929418" - integrity sha512-mW7W8dKuVYefCpNzE3Z7xUmPI9wSrSL/1qH31YGMxmSOAnjatS3S9Zv3cmiHrhx3Jkp1SrWWBdOFXjfF48Uq3A== - dependencies: - debug "^2.1.2" - iconv-lite "^0.4.4" - sax "^1.2.4" - negotiator@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9" @@ -8248,10 +8641,10 @@ next-tick@~1.0.0: resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c" integrity sha1-yobR/ogoFpsBICCOPchCS524NCw= -ng-mocks@^7.6.0: - version "7.8.0" - resolved "https://registry.yarnpkg.com/ng-mocks/-/ng-mocks-7.8.0.tgz#05b9f46164ca8d22b72ee9ac2369f37b2434ceb2" - integrity sha512-QopwqQUeEoUmHZdJeEDG3koJ5woHYeVe3TEfRjVHwLyLfJ/wdcNcepdei6j9n9Ne42SpS2bOETgKBqUT5NUsng== +ng-mocks@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/ng-mocks/-/ng-mocks-8.1.0.tgz#d00a5e53ae53587f35c68147826590fab71a1658" + integrity sha512-/314nyU6UrONCUKfvFRuJPLpNBxqocwJmQBlPy4he5Vueu6gObXjy+KLUlbbENuA7zTeBjp//RA6w/Fa1yQ4Fw== ng2-file-upload@1.2.1: version "1.2.1" @@ -8263,17 +8656,10 @@ ng2-nouislider@^1.8.2: resolved "https://registry.yarnpkg.com/ng2-nouislider/-/ng2-nouislider-1.8.2.tgz#4d4aab402d307020415da1714a5e9f46817fe97c" integrity sha512-apCpRxwX/3VapLuPozZkUfM3HAE1unuCm2UdRMDvAHbbY6CLobaZcsWUYQ6b02VzxccyV4G1z0xsq2un8J2Lqw== -ngrx-store-freeze@^0.2.4: - version "0.2.4" - resolved "https://registry.yarnpkg.com/ngrx-store-freeze/-/ngrx-store-freeze-0.2.4.tgz#146687cdf7e21244eb9003c7e883f2125847076c" - integrity sha512-90awpbbMa/x2H81eWWYniyli3LJ1PZU/FaztL10d9Rp/4kw2+97pqyLjdxSPxcOv9St//m9kfuWZ7gyoVDjgcg== - dependencies: - deep-freeze-strict "^1.1.1" - -ngx-bootstrap@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/ngx-bootstrap/-/ngx-bootstrap-3.2.0.tgz#ece7c48af0bc260462c3f77de14f22d4b3dde149" - integrity sha512-oLSLIWZgRiIfcuxyXLMZUOhX3wZtg6lpuMbdo/0UzMDg2bSOe1XPskcKZ/iuOa3FOlU9rjuYMzswHYYV5f/QCA== +ngx-bootstrap@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/ngx-bootstrap/-/ngx-bootstrap-5.3.2.tgz#0668b01202610657e998b3ca87669645e0b31dc9" + integrity sha512-gSMf8EXYl99Q3gqkq4RVhoTNSTYHz2Or6Cig2BJRbLJyqk15ZQE5qcq/ldHS8zzx/wgCA3HQeI63t2j2mEU9PA== ngx-infinite-scroll@6.0.1: version "6.0.1" @@ -8294,6 +8680,13 @@ ngx-pagination@3.0.3: resolved "https://registry.yarnpkg.com/ngx-pagination/-/ngx-pagination-3.0.3.tgz#314145263613738d8c544da36cd8dacc5aa89a6f" integrity sha1-MUFFJjYTc42MVE2jbNjazFqomm8= +ngx-sortablejs@^3.1.4: + version "3.1.4" + resolved "https://registry.yarnpkg.com/ngx-sortablejs/-/ngx-sortablejs-3.1.4.tgz#8c2e29f8f6c0ea6cdf8298ae1438b522d0ed70b9" + integrity sha512-jrEC4loWf01JxBcPiOHiyHbXwNrs4acIus1c9NVv7hiK574dywoqOnL5/OVQFnluqItWC7llqCUXfDr3Kmyqfg== + dependencies: + tslib "^1.9.0" + nice-try@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" @@ -8333,53 +8726,6 @@ node-forge@0.9.0: resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.0.tgz#d624050edbb44874adca12bb9a52ec63cb782579" integrity sha512-7ASaDa3pD+lJ3WvXFsxekJQelBKRpne+GOVbLbtHYdd7pFspyeuJHnWfLplGf3SwKGbfs/aYl5V/JCIaHVUKKQ== -node-gyp@^3.8.0: - version "3.8.0" - resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-3.8.0.tgz#540304261c330e80d0d5edce253a68cb3964218c" - integrity sha512-3g8lYefrRRzvGeSowdJKAKyks8oUpLEd/DyPV4eMhVlhJ0aNaZqIrNUIPuEWWTAoPqyFkfGrM67MC69baqn6vA== - dependencies: - fstream "^1.0.0" - glob "^7.0.3" - graceful-fs "^4.1.2" - mkdirp "^0.5.0" - nopt "2 || 3" - npmlog "0 || 1 || 2 || 3 || 4" - osenv "0" - request "^2.87.0" - rimraf "2" - semver "~5.3.0" - tar "^2.0.0" - which "1" - -node-libs-browser@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.1.0.tgz#5f94263d404f6e44767d726901fff05478d600df" - integrity sha512-5AzFzdoIMb89hBGMZglEegffzgRg+ZFoUmisQ8HI4j1KDdpx13J0taNp2y9xPbur6W61gepGDDotGBVQ7mfUCg== - dependencies: - assert "^1.1.1" - browserify-zlib "^0.2.0" - buffer "^4.3.0" - console-browserify "^1.1.0" - constants-browserify "^1.0.0" - crypto-browserify "^3.11.0" - domain-browser "^1.1.1" - events "^1.0.0" - https-browserify "^1.0.0" - os-browserify "^0.3.0" - path-browserify "0.0.0" - process "^0.11.10" - punycode "^1.2.4" - querystring-es3 "^0.2.0" - readable-stream "^2.3.3" - stream-browserify "^2.0.1" - stream-http "^2.7.2" - string_decoder "^1.0.0" - timers-browserify "^2.0.4" - tty-browserify "0.0.0" - url "^0.11.0" - util "^0.10.3" - vm-browserify "0.0.4" - node-libs-browser@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.2.1.tgz#b64f513d18338625f90346d27b0d235e631f6425" @@ -8409,38 +8755,6 @@ node-libs-browser@^2.2.1: util "^0.11.0" vm-browserify "^1.0.1" -node-pre-gyp@^0.10.0: - version "0.10.3" - resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.10.3.tgz#3070040716afdc778747b61b6887bf78880b80fc" - integrity sha512-d1xFs+C/IPS8Id0qPTZ4bUT8wWryfR/OzzAFxweG+uLN85oPzyo2Iw6bVlLQ/JOdgNonXLCoRyqDzDWq4iw72A== - dependencies: - detect-libc "^1.0.2" - mkdirp "^0.5.1" - needle "^2.2.1" - nopt "^4.0.1" - npm-packlist "^1.1.6" - npmlog "^4.0.2" - rc "^1.2.7" - rimraf "^2.6.1" - semver "^5.3.0" - tar "^4" - -node-pre-gyp@^0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.12.0.tgz#39ba4bb1439da030295f899e3b520b7785766149" - integrity sha512-4KghwV8vH5k+g2ylT+sLTjy5wmUOb9vPhnM8NHvRf9dHmnW/CndrFXy2aRPaPST6dugXSdHXfeaHQm77PIz/1A== - dependencies: - detect-libc "^1.0.2" - mkdirp "^0.5.1" - needle "^2.2.1" - nopt "^4.0.1" - npm-packlist "^1.1.6" - npmlog "^4.0.2" - rc "^1.2.7" - rimraf "^2.6.1" - semver "^5.3.0" - tar "^4" - node-releases@^1.0.0-alpha.11: version "1.0.0-alpha.11" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.0.0-alpha.11.tgz#73c810acc2e5b741a17ddfbb39dfca9ab9359d8a" @@ -8448,10 +8762,10 @@ node-releases@^1.0.0-alpha.11: dependencies: semver "^5.3.0" -node-releases@^1.1.42: - version "1.1.42" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.42.tgz#a999f6a62f8746981f6da90627a8d2fc090bbad7" - integrity sha512-OQ/ESmUqGawI2PRX+XIRao44qWYBBfN54ImQYdWVTQqUckuejOg76ysSqDBK8NG3zwySRVnX36JwDQ6x+9GxzA== +node-releases@^1.1.47: + version "1.1.47" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.47.tgz#c59ef739a1fd7ecbd9f0b7cf5b7871e8a8b591e4" + integrity sha512-k4xjVPx5FpwBUj0Gw7uvFOTF4Ep8Hok1I6qjwL3pLfwe7Y0REQSAqOwwv9TWBCUtMHxcXfY4PgRLRozcChvTcA== dependencies: semver "^6.3.0" @@ -8462,54 +8776,6 @@ node-releases@^1.1.8: dependencies: semver "^5.3.0" -node-sass@4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.12.0.tgz#0914f531932380114a30cc5fa4fa63233a25f017" - integrity sha512-A1Iv4oN+Iel6EPv77/HddXErL2a+gZ4uBeZUy+a8O35CFYTXhgA8MgLCWBtwpGZdCvTvQ9d+bQxX/QC36GDPpQ== - dependencies: - async-foreach "^0.1.3" - chalk "^1.1.1" - cross-spawn "^3.0.0" - gaze "^1.0.0" - get-stdin "^4.0.1" - glob "^7.0.3" - in-publish "^2.0.0" - lodash "^4.17.11" - meow "^3.7.0" - mkdirp "^0.5.1" - nan "^2.13.2" - node-gyp "^3.8.0" - npmlog "^4.0.0" - request "^2.88.0" - sass-graph "^2.2.4" - stdout-stream "^1.4.0" - "true-case-path" "^1.0.2" - -node-sass@^4.11.0: - version "4.11.0" - resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.11.0.tgz#183faec398e9cbe93ba43362e2768ca988a6369a" - integrity sha512-bHUdHTphgQJZaF1LASx0kAviPH7sGlcyNhWade4eVIpFp6tsn7SV8xNMTbsQFpEV9VXpnwTTnNYlfsZXgGgmkA== - dependencies: - async-foreach "^0.1.3" - chalk "^1.1.1" - cross-spawn "^3.0.0" - gaze "^1.0.0" - get-stdin "^4.0.1" - glob "^7.0.3" - in-publish "^2.0.0" - lodash.assign "^4.2.0" - lodash.clonedeep "^4.3.2" - lodash.mergewith "^4.6.0" - meow "^3.7.0" - mkdirp "^0.5.1" - nan "^2.10.0" - node-gyp "^3.8.0" - npmlog "^4.0.0" - request "^2.88.0" - sass-graph "^2.2.4" - stdout-stream "^1.4.0" - "true-case-path" "^1.0.2" - nodemon@^1.15.0: version "1.18.4" resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-1.18.4.tgz#873f65fdb53220eb166180cf106b1354ac5d714d" @@ -8534,21 +8800,13 @@ noms@0.0.0: inherits "^2.0.1" readable-stream "~1.0.31" -"nopt@2 || 3", nopt@3.x: +nopt@3.x: version "3.0.6" resolved "https://registry.yarnpkg.com/nopt/-/nopt-3.0.6.tgz#c6465dbf08abcd4db359317f79ac68a646b28ff9" integrity sha1-xkZdvwirzU2zWTF/eaxopkayj/k= dependencies: abbrev "1" -nopt@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d" - integrity sha1-0NRoWv1UFRk8jHUFYC0NF81kR00= - dependencies: - abbrev "1" - osenv "^0.1.4" - nopt@~1.0.10: version "1.0.10" resolved "https://registry.yarnpkg.com/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee" @@ -8556,6 +8814,16 @@ nopt@~1.0.10: dependencies: abbrev "1" +normalize-package-data@^2.0.0, normalize-package-data@^2.4.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" + integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA== + dependencies: + hosted-git-info "^2.1.4" + resolve "^1.10.0" + semver "2 || 3 || 4 || 5" + validate-npm-package-license "^3.0.1" + normalize-package-data@^2.3.2, normalize-package-data@^2.3.4: version "2.4.0" resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.4.0.tgz#12f95a307d58352075a04907b84ac8be98ac012f" @@ -8566,16 +8834,6 @@ normalize-package-data@^2.3.2, normalize-package-data@^2.3.4: semver "2 || 3 || 4 || 5" validate-npm-package-license "^3.0.1" -normalize-package-data@^2.4.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" - integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA== - dependencies: - hosted-git-info "^2.1.4" - resolve "^1.10.0" - semver "2 || 3 || 4 || 5" - validate-npm-package-license "^3.0.1" - normalize-path@^2.0.1, normalize-path@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" @@ -8583,7 +8841,7 @@ normalize-path@^2.0.1, normalize-path@^2.1.1: dependencies: remove-trailing-separator "^1.0.1" -normalize-path@^3.0.0: +normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== @@ -8593,6 +8851,16 @@ normalize-range@^0.1.2: resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI= +normalize-url@1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-1.9.1.tgz#2cc0d66b31ea23036458436e3620d85954c66c3c" + integrity sha1-LMDWazHqIwNkWENuNiDYWVTGbDw= + dependencies: + object-assign "^4.0.1" + prepend-http "^1.0.0" + query-string "^4.1.0" + sort-keys "^1.0.0" + normalize-url@^3.0.0: version "3.3.0" resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-3.3.0.tgz#b2e1c4dc4f7c6d57743df733a4f5978d18650559" @@ -8608,6 +8876,11 @@ npm-bundled@^1.0.1: resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.5.tgz#3c1732b7ba936b3a10325aef616467c0ccbcc979" integrity sha512-m/e6jgWu8/v5niCUKQi9qQl8QdeEduFA96xHDDzFGqly0OOjI7c+60KM/2sppfnUU9JJagf+zs+yGhqSOFj71g== +npm-normalize-package-bin@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz#6e79a41f23fd235c0623218228da7d9c23b8f6e2" + integrity sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA== + npm-package-arg@6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/npm-package-arg/-/npm-package-arg-6.1.0.tgz#15ae1e2758a5027efb4c250554b85a737db7fcc1" @@ -8636,13 +8909,14 @@ npm-packlist@^1.1.12: ignore-walk "^3.0.1" npm-bundled "^1.0.1" -npm-packlist@^1.1.6: - version "1.1.11" - resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.1.11.tgz#84e8c683cbe7867d34b1d357d893ce29e28a02de" - integrity sha512-CxKlZ24urLkJk+9kCm48RTQ7L4hsmgSVzEk0TLGPzzyuFxD7VNgy5Sl24tOLMzQv773a/NeJ1ce1DKeacqffEA== +npm-pick-manifest@3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/npm-pick-manifest/-/npm-pick-manifest-3.0.2.tgz#f4d9e5fd4be2153e5f4e5f9b7be8dc419a99abb7" + integrity sha512-wNprTNg+X5nf+tDi+hbjdHhM4bX+mKqv6XmPh7B5eG+QY9VARfQPfCEH013H5GqfNj6ee8Ij2fg8yk0mzps1Vw== dependencies: - ignore-walk "^3.0.1" - npm-bundled "^1.0.1" + figgy-pudding "^3.5.1" + npm-package-arg "^6.0.0" + semver "^5.4.1" npm-pick-manifest@^2.2.3: version "2.2.3" @@ -8653,17 +8927,18 @@ npm-pick-manifest@^2.2.3: npm-package-arg "^6.0.0" semver "^5.4.1" -npm-registry-fetch@^3.8.0: - version "3.9.1" - resolved "https://registry.yarnpkg.com/npm-registry-fetch/-/npm-registry-fetch-3.9.1.tgz#00ff6e4e35d3f75a172b332440b53e93f4cb67de" - integrity sha512-VQCEZlydXw4AwLROAXWUR7QDfe2Y8Id/vpAgp6TI1/H78a4SiQ1kQrKZALm5/zxM5n4HIi+aYb+idUAV/RuY0Q== +npm-registry-fetch@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/npm-registry-fetch/-/npm-registry-fetch-4.0.2.tgz#2b1434f93ccbe6b6385f8e45f45db93e16921d7a" + integrity sha512-Z0IFtPEozNdeZRPh3aHHxdG+ZRpzcbQaJLthsm3VhNf6DScicTFRHZzK82u8RsJUsUHkX+QH/zcB/5pmd20H4A== dependencies: JSONStream "^1.3.4" bluebird "^3.5.1" figgy-pudding "^3.4.1" lru-cache "^5.1.1" - make-fetch-happen "^4.0.2" + make-fetch-happen "^5.0.0" npm-package-arg "^6.1.0" + safe-buffer "^5.2.0" npm-run-all@4.1.3: version "4.1.3" @@ -8687,16 +8962,6 @@ npm-run-path@^2.0.0, npm-run-path@^2.0.2: dependencies: path-key "^2.0.0" -"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.0, npmlog@^4.0.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" - integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== - dependencies: - are-we-there-yet "~1.1.2" - console-control-strings "~1.1.0" - gauge "~2.7.3" - set-blocking "~2.0.0" - nth-check@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c" @@ -8858,10 +9123,10 @@ onetime@^5.1.0: dependencies: mimic-fn "^2.1.0" -open@6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/open/-/open-6.0.0.tgz#cae5e2c1a3a1bfaee0d0acc8c4b7609374750346" - integrity sha512-/yb5mVZBz7mHLySMiSj2DcLtMBbFPJk5JBKEkHVZFxZAPzeg3L026O0T+lbdz1B2nyDnkClRSwRQJdeVUIF7zw== +open@6.4.0: + version "6.4.0" + resolved "https://registry.yarnpkg.com/open/-/open-6.4.0.tgz#5c13e96d0dc894686164f18965ecfe889ecfc8a9" + integrity sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg== dependencies: is-wsl "^1.1.0" @@ -8895,7 +9160,7 @@ opn@4.0.2: object-assign "^4.0.1" pinkie-promise "^2.0.0" -opn@^5.1.0, opn@^5.5.0: +opn@^5.5.0: version "5.5.0" resolved "https://registry.yarnpkg.com/opn/-/opn-5.5.0.tgz#fc7164fab56d235904c51c3b27da6758ca3b9bfc" integrity sha512-PqHpggC9bLV0VeWcdKhkpxY+3JTzetLSqTCWL/z/tFIbI6G8JCjondXklT1JinczLz2Xib62sSp0T/gKT4KksA== @@ -8959,22 +9224,6 @@ os-homedir@^1.0.0: resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M= -os-locale@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-1.4.0.tgz#20f9f17ae29ed345e8bde583b13d2009803c14d9" - integrity sha1-IPnxeuKe00XoveWDsT0gCYA8FNk= - dependencies: - lcid "^1.0.0" - -os-locale@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-2.1.0.tgz#42bc2900a6b5b8bd17376c8e882b65afccf24bf2" - integrity sha512-3sslG3zJbEYcaC4YVAvDorjGxc7tv6KVATnLPZONiljsUncvihe9BQoVCEs0RZ1kmf4Hk9OBqlZfJZWI4GanKA== - dependencies: - execa "^0.7.0" - lcid "^1.0.0" - mem "^1.1.0" - os-locale@^3.0.0, os-locale@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-3.1.0.tgz#a802a6ee17f24c10483ab9935719cef4ed16bf1a" @@ -8989,7 +9238,7 @@ os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.1, os-tmpdir@~1.0.2: resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= -osenv@0, osenv@^0.1.4, osenv@^0.1.5: +osenv@^0.1.5: version "0.1.5" resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410" integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g== @@ -9012,13 +9261,6 @@ p-is-promise@^2.0.0: resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-2.1.0.tgz#918cebaea248a62cf7ffab8e3bca8c5f882fc42e" integrity sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg== -p-limit@^1.0.0, p-limit@^1.1.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8" - integrity sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q== - dependencies: - p-try "^1.0.0" - p-limit@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.0.0.tgz#e624ed54ee8c460a778b3c9f3670496ff8a57aec" @@ -9033,13 +9275,6 @@ p-limit@^2.2.0, p-limit@^2.2.1: dependencies: p-try "^2.0.0" -p-locate@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" - integrity sha1-IKAQOyIqcMj9OcwuWAaA893l7EM= - dependencies: - p-limit "^1.1.0" - p-locate@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" @@ -9054,11 +9289,6 @@ p-locate@^4.1.0: dependencies: p-limit "^2.2.0" -p-map@^1.1.1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b" - integrity sha512-r6zKACMNhjPJMTl8KcFH4li//gkrXWfbD6feV8l6doRHlzljFWGJ2AP6iKaCJXyZmAUMOPtvbW7EXkbWO/pLEA== - p-map@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/p-map/-/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175" @@ -9078,11 +9308,6 @@ p-retry@^3.0.1: dependencies: retry "^0.12.0" -p-try@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" - integrity sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M= - p-try@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.0.0.tgz#85080bb87c64688fa47996fe8f7dfbe8211760b1" @@ -9098,18 +9323,19 @@ package-json@^4.0.0: registry-url "^3.0.3" semver "^5.1.0" -pacote@9.4.0: - version "9.4.0" - resolved "https://registry.yarnpkg.com/pacote/-/pacote-9.4.0.tgz#af979abdeb175cd347c3e33be3241af1ed254807" - integrity sha512-WQ1KL/phGMkedYEQx9ODsjj7xvwLSpdFJJdEXrLyw5SILMxcTNt5DTxT2Z93fXuLFYJBlZJdnwdalrQdB/rX5w== +pacote@9.5.5: + version "9.5.5" + resolved "https://registry.yarnpkg.com/pacote/-/pacote-9.5.5.tgz#63355a393614c3424e735820c3731e2cbbedaeeb" + integrity sha512-jAEP+Nqj4kyMWyNpfTU/Whx1jA7jEc5cCOlurm0/0oL+v8TAp1QSsK83N7bYe+2bEdFzMAtPG5TBebjzzGV0cA== dependencies: bluebird "^3.5.3" - cacache "^11.3.2" + cacache "^12.0.2" figgy-pudding "^3.5.1" get-stream "^4.1.0" glob "^7.1.3" + infer-owner "^1.0.4" lru-cache "^5.1.1" - make-fetch-happen "^4.0.1" + make-fetch-happen "^5.0.0" minimatch "^3.0.4" minipass "^2.3.5" mississippi "^3.0.0" @@ -9118,7 +9344,7 @@ pacote@9.4.0: npm-package-arg "^6.1.0" npm-packlist "^1.1.12" npm-pick-manifest "^2.2.3" - npm-registry-fetch "^3.8.0" + npm-registry-fetch "^4.0.0" osenv "^0.1.5" promise-inflight "^1.0.1" promise-retry "^1.1.1" @@ -9244,11 +9470,6 @@ pascalcase@^0.1.1: resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ= -path-browserify@0.0.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.0.tgz#a0b870729aae214005b7d5032ec2cbbb0fb4451a" - integrity sha1-oLhwcpquIUAFt9UDLsLLuw+0RRo= - path-browserify@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.1.tgz#e6c4ddd7ed3aa27c68a20cc4e50e1a4ee83bbc4a" @@ -9310,13 +9531,6 @@ path-type@^1.0.0: pify "^2.0.0" pinkie-promise "^2.0.0" -path-type@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-2.0.0.tgz#f012ccb8415b7096fc2daa1054c3d72389594c73" - integrity sha1-8BLMuEFbcJb8LaoQVMPXI4lZTHM= - dependencies: - pify "^2.0.0" - path-type@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f" @@ -9377,6 +9591,11 @@ phantomjs-prebuilt@^2.1.7: request-progress "^2.0.1" which "^1.2.10" +picomatch@^2.0.4, picomatch@^2.0.7: + version "2.2.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.1.tgz#21bac888b6ed8601f831ce7816e335bc779f0a4a" + integrity sha512-ISBaA8xQNmwELC7eOjqFKMESB2VIqt4PPDD0nsS95b/9dZXvVKOlz9keMSnoGGKcOHXfTvDD6WMaRoSc9UuhRA== + pify@^2.0.0, pify@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" @@ -9413,13 +9632,6 @@ pixrem@^4.0.0: postcss "^6.0.0" reduce-css-calc "^1.2.7" -pkg-dir@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-2.0.0.tgz#f6d5d1109e19d63edf428e0bd57e12777615334b" - integrity sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s= - dependencies: - find-up "^2.1.0" - pkg-dir@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-3.0.0.tgz#2749020f239ed990881b1f71210d51eb6523bea3" @@ -9481,7 +9693,7 @@ portfinder@^1.0.20: debug "^2.2.0" mkdirp "0.5.x" -portfinder@^1.0.9: +portfinder@^1.0.25: version "1.0.25" resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.25.tgz#254fd337ffba869f4b9d37edc298059cb4d35eca" integrity sha512-6ElJnHBbxVA1XSLgBp7G1FiCkQdlqGzuF7DswL5tcea+E8UpuvPU7beVAjjRwCioTS9ZluNbu+ZyRvgTsmqEBg== @@ -10192,10 +10404,10 @@ postcss-values-parser@^1.5.0: indexes-of "^1.0.1" uniq "^1.0.1" -postcss@7.0.14, postcss@^7.0.1, postcss@^7.0.5: - version "7.0.14" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.14.tgz#4527ed6b1ca0d82c53ce5ec1a2041c2346bbd6e5" - integrity sha512-NsbD6XUUMZvBxtQAJuWDJeeC4QFsmWsfozWxCJPWf3M55K9iu2iMDaKqyoOdTJ1R4usBXuxlVFAIo8rZPQD4Bg== +postcss@7.0.17: + version "7.0.17" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.17.tgz#4da1bdff5322d4a0acaab4d87f3e782436bad31f" + integrity sha512-546ZowA+KZ3OasvQZHsbuEpysvwTZNGJv9EfyCQdsIDltPSWHAeTQ5fQy/Npi2ZDtLI3zs7Ps/p6wThErhm9fQ== dependencies: chalk "^2.4.2" source-map "^0.6.1" @@ -10210,7 +10422,7 @@ postcss@^6.0, postcss@^6.0.0, postcss@^6.0.1, postcss@^6.0.11, postcss@^6.0.14, source-map "^0.6.1" supports-color "^5.4.0" -postcss@^7.0.0, postcss@^7.0.13, postcss@^7.0.14, postcss@^7.0.16, postcss@^7.0.23, postcss@^7.0.6: +postcss@^7.0.0, postcss@^7.0.14, postcss@^7.0.16, postcss@^7.0.23, postcss@^7.0.6: version "7.0.25" resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.25.tgz#dd2a2a753d50b13bed7a2009b4a18ac14d9db21e" integrity sha512-NXXVvWq9icrm/TgQC0O6YVFi4StfJz46M1iNd/h6B26Nvh/HKI+q4YZtFN/EjcInZliEscO/WL10BXnc1E5nwg== @@ -10219,6 +10431,24 @@ postcss@^7.0.0, postcss@^7.0.13, postcss@^7.0.14, postcss@^7.0.16, postcss@^7.0. source-map "^0.6.1" supports-color "^6.1.0" +postcss@^7.0.1, postcss@^7.0.5: + version "7.0.14" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.14.tgz#4527ed6b1ca0d82c53ce5ec1a2041c2346bbd6e5" + integrity sha512-NsbD6XUUMZvBxtQAJuWDJeeC4QFsmWsfozWxCJPWf3M55K9iu2iMDaKqyoOdTJ1R4usBXuxlVFAIo8rZPQD4Bg== + dependencies: + chalk "^2.4.2" + source-map "^0.6.1" + supports-color "^6.1.0" + +postcss@^7.0.17: + version "7.0.26" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.26.tgz#5ed615cfcab35ba9bbb82414a4fa88ea10429587" + integrity sha512-IY4oRjpXWYshuTDFxMVkJDtWIk2LhsTlu8bZnbEJA4+bYT16Lvpo8Qv6EvDumhYRgzjZl489pmsY3qVgJQ08nA== + dependencies: + chalk "^2.4.2" + source-map "^0.6.1" + supports-color "^6.1.0" + postcss@^7.0.2: version "7.0.2" resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.2.tgz#7b5a109de356804e27f95a960bef0e4d5bc9bb18" @@ -10233,7 +10463,7 @@ prelude-ls@~1.1.2: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= -prepend-http@^1.0.1: +prepend-http@^1.0.0, prepend-http@^1.0.1: version "1.0.4" resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw= @@ -10256,6 +10486,11 @@ pretty-hrtime@^1.0.3: resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" integrity sha1-t+PqQkNaTJsnWdmeDyAesZWALuE= +private@^0.1.6: + version "0.1.8" + resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff" + integrity sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg== + process-es6@^0.11.6: version "0.11.6" resolved "https://registry.yarnpkg.com/process-es6/-/process-es6-0.11.6.tgz#c6bb389f9a951f82bd4eb169600105bd2ff9c778" @@ -10407,7 +10642,7 @@ public-encrypt@^4.0.0: parse-asn1 "^5.0.0" randombytes "^2.0.1" -pump@^2.0.0, pump@^2.0.1: +pump@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/pump/-/pump-2.0.1.tgz#12399add6e4cf7526d973cbc8b5ce2e2908b3909" integrity sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA== @@ -10482,6 +10717,14 @@ qs@~2.3.3: resolved "https://registry.yarnpkg.com/qs/-/qs-2.3.3.tgz#e9e85adbe75da0bbe4c8e0476a086290f863b404" integrity sha1-6eha2+ddoLvkyOBHaghikPhjtAQ= +query-string@^4.1.0: + version "4.3.4" + resolved "https://registry.yarnpkg.com/query-string/-/query-string-4.3.4.tgz#bbb693b9ca915c232515b228b1a02b609043dbeb" + integrity sha1-u7aTucqRXCMlFbIosaArYJBD2+s= + dependencies: + object-assign "^4.1.0" + strict-uri-encode "^1.0.0" + querystring-es3@^0.2.0: version "0.2.1" resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" @@ -10576,15 +10819,15 @@ raw-loader@0.5.1: resolved "https://registry.yarnpkg.com/raw-loader/-/raw-loader-0.5.1.tgz#0c3d0beaed8a01c966d9787bf778281252a979aa" integrity sha1-DD0L6u2KAclm2Xh793goElKpeao= -raw-loader@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/raw-loader/-/raw-loader-1.0.0.tgz#3f9889e73dadbda9a424bce79809b4133ad46405" - integrity sha512-Uqy5AqELpytJTRxYT4fhltcKPj0TyaEpzJDcGz7DFJi+pQOOi3GjR/DOdxTkTsF+NzhnldIoG6TORaBlInUuqA== +raw-loader@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/raw-loader/-/raw-loader-3.1.0.tgz#5e9d399a5a222cc0de18f42c3bc5e49677532b3f" + integrity sha512-lzUVMuJ06HF4rYveaz9Tv0WRlUMxJ0Y1hgSkkgg+50iEdaI0TthyEDe08KIHb0XsF6rn8WYTqPCaGTZg3sX+qA== dependencies: loader-utils "^1.1.0" - schema-utils "^1.0.0" + schema-utils "^2.0.1" -rc@^1.0.1, rc@^1.1.6, rc@^1.2.7: +rc@^1.0.1, rc@^1.1.6: version "1.2.8" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== @@ -10601,6 +10844,27 @@ read-cache@^1.0.0: dependencies: pify "^2.3.0" +read-package-json@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/read-package-json/-/read-package-json-2.1.1.tgz#16aa66c59e7d4dad6288f179dd9295fd59bb98f1" + integrity sha512-dAiqGtVc/q5doFz6096CcnXhpYk0ZN8dEKVkGLU0CsASt8SrgF6SF7OTKAYubfvFhWaqofl+Y8HK19GR8jwW+A== + dependencies: + glob "^7.1.1" + json-parse-better-errors "^1.0.1" + normalize-package-data "^2.0.0" + npm-normalize-package-bin "^1.0.0" + optionalDependencies: + graceful-fs "^4.1.2" + +read-package-tree@5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/read-package-tree/-/read-package-tree-5.3.1.tgz#a32cb64c7f31eb8a6f31ef06f9cedf74068fe636" + integrity sha512-mLUDsD5JVtlZxjSlPPx1RETkNjjvQYuweKwNVt1Sn8kP5Jh44pvYuUHCp6xSVDZWbNxVxG5lyZJ921aJH61sTw== + dependencies: + read-package-json "^2.0.0" + readdir-scoped-modules "^1.0.0" + util-promisify "^2.1.0" + read-pkg-up@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02" @@ -10609,14 +10873,6 @@ read-pkg-up@^1.0.1: find-up "^1.0.0" read-pkg "^1.0.0" -read-pkg-up@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-2.0.0.tgz#6b72a8048984e0c41e79510fd5e9fa99b3b549be" - integrity sha1-a3KoBImE4MQeeVEP1en6mbO1Sb4= - dependencies: - find-up "^2.0.0" - read-pkg "^2.0.0" - read-pkg@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-1.1.0.tgz#f5ffaa5ecd29cb31c0474bca7d756b6bb29e3f28" @@ -10626,15 +10882,6 @@ read-pkg@^1.0.0: normalize-package-data "^2.3.2" path-type "^1.0.0" -read-pkg@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-2.0.0.tgz#8ef1c0623c6a6db0dc6713c4bfac46332b2368f8" - integrity sha1-jvHAYjxqbbDcZxPEv6xGMysjaPg= - dependencies: - load-json-file "^2.0.0" - normalize-package-data "^2.3.2" - path-type "^2.0.0" - read-pkg@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-3.0.0.tgz#9cbc686978fee65d16c00e2b19c237fcf6e38389" @@ -10644,7 +10891,7 @@ read-pkg@^3.0.0: normalize-package-data "^2.3.2" path-type "^3.0.0" -"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6: +"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6: version "2.3.6" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf" integrity sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw== @@ -10698,6 +10945,16 @@ readable-stream@~2.0.0, readable-stream@~2.0.6: string_decoder "~0.10.x" util-deprecate "~1.0.1" +readdir-scoped-modules@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz#8d45407b4f870a0dcaebc0e28670d18e74514309" + integrity sha512-asaikDeqAQg7JifRsZn1NJZXo9E+VwlyCfbkZhwyISinqk5zNS6266HS5kah6P0SaQKGF6SkNnZVHUzHFYxYDw== + dependencies: + debuglog "^1.0.1" + dezalgo "^1.0.0" + graceful-fs "^4.1.2" + once "^1.3.0" + readdirp@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.1.0.tgz#4ed0ad060df3073300c48440373f72d1cc642d78" @@ -10717,6 +10974,13 @@ readdirp@^2.2.1: micromatch "^3.1.10" readable-stream "^2.0.2" +readdirp@~3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.3.0.tgz#984458d13a1e42e2e9f5841b129e162f369aff17" + integrity sha512-zz0pAkSPOXXm1viEwygWIPSPkcBYjW1xU5j/JBh5t9bGCJwa6f9+BJa6VaB2g+b55yVrmXzqkyLf4xaWYM0IkQ== + dependencies: + picomatch "^2.0.7" + rechoir@^0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" @@ -10756,16 +11020,33 @@ reduce-function-call@^1.0.1, reduce-function-call@^1.0.2: dependencies: balanced-match "^0.4.2" -reflect-metadata@0.1.12, reflect-metadata@^0.1.2: +reflect-metadata@^0.1.13: + version "0.1.13" + resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.13.tgz#67ae3ca57c972a2aa1642b10fe363fe32d49dc08" + integrity sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg== + +reflect-metadata@^0.1.2: version "0.1.12" resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.1.12.tgz#311bf0c6b63cd782f228a81abe146a2bfa9c56f2" integrity sha512-n+IyV+nGz3+0q3/Yf1ra12KpCyi001bi4XFxSjbiWWjfqb52iTTtpGXmCCAOWWIAn9KEuFZKGqBERHmrtScZ3A== -regenerate@^1.2.1: +regenerate-unicode-properties@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.1.0.tgz#ef51e0f0ea4ad424b77bf7cb41f3e015c70a3f0e" + integrity sha512-LGZzkgtLY79GeXLm8Dp0BVLdQlWICzBnJz/ipWUgo59qBaZ+BHtq51P2q1uVZlppMuUAT37SDk39qUbjTWB7bA== + dependencies: + regenerate "^1.4.0" + +regenerate@^1.2.1, regenerate@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.0.tgz#4a856ec4b56e4077c557589cae85e7a4c8869a11" integrity sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg== +regenerator-runtime@0.13.3, regenerator-runtime@^0.13.2: + version "0.13.3" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz#7cf6a77d8f5c6f60eb73c5fc1955b2ceb01e6bf5" + integrity sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw== + regenerator-runtime@^0.10.0: version "0.10.5" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz#336c3efc1220adcedda2c9fab67b5a7955a33658" @@ -10776,10 +11057,12 @@ regenerator-runtime@^0.11.0: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== -regenerator-runtime@^0.13.2: - version "0.13.3" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz#7cf6a77d8f5c6f60eb73c5fc1955b2ceb01e6bf5" - integrity sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw== +regenerator-transform@^0.14.0: + version "0.14.1" + resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.1.tgz#3b2fce4e1ab7732c08f665dfdb314749c7ddd2fb" + integrity sha512-flVuee02C3FKRISbxhXl9mGzdbWUVHubl1SMaknjxkFB1/iqpJhArQUvRxOOPEc/9tAiX0BaQ28FJH10E4isSQ== + dependencies: + private "^0.1.6" regex-cache@^0.4.2: version "0.4.4" @@ -10815,6 +11098,18 @@ regexpu-core@^1.0.0: regjsgen "^0.2.0" regjsparser "^0.1.4" +regexpu-core@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.6.0.tgz#2037c18b327cfce8a6fea2a4ec441f2432afb8b6" + integrity sha512-YlVaefl8P5BnFYOITTNzDvan1ulLOiXJzCNZxduTIosN17b87h3bvG9yHMoHaRuo88H4mQ06Aodj5VtYGGGiTg== + dependencies: + regenerate "^1.4.0" + regenerate-unicode-properties "^8.1.0" + regjsgen "^0.5.0" + regjsparser "^0.6.0" + unicode-match-property-ecmascript "^1.0.4" + unicode-match-property-value-ecmascript "^1.1.0" + registry-auth-token@^3.0.1: version "3.3.2" resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-3.3.2.tgz#851fd49038eecb586911115af845260eec983f20" @@ -10835,6 +11130,11 @@ regjsgen@^0.2.0: resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.2.0.tgz#6c016adeac554f75823fe37ac05b92d5a4edb1f7" integrity sha1-bAFq3qxVT3WCP+N6wFuS1aTtsfc= +regjsgen@^0.5.0: + version "0.5.1" + resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.5.1.tgz#48f0bf1a5ea205196929c0d9798b42d1ed98443c" + integrity sha512-5qxzGZjDs9w4tzT3TPhCJqWdCc3RLYwy9J2NB0nm5Lz+S273lvWcpjaTGHsT1dc6Hhfq41uSEOw8wBmxrKOuyg== + regjsparser@^0.1.4: version "0.1.5" resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.1.5.tgz#7ee8f84dc6fa792d3fd0ae228d24bd949ead205c" @@ -10842,6 +11142,13 @@ regjsparser@^0.1.4: dependencies: jsesc "~0.5.0" +regjsparser@^0.6.0: + version "0.6.2" + resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.6.2.tgz#fd62c753991467d9d1ffe0a9f67f27a529024b96" + integrity sha512-E9ghzUtoLwDekPT0DYCp+c4h+bvuUpe6rRHCTYn6eGoqj1LgKXxT6I0Il4WbjhQkOghzi/V+y03bPKvbllL93Q== + dependencies: + jsesc "~0.5.0" + relateurl@0.2.x: version "0.2.7" resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" @@ -11067,7 +11374,7 @@ rgba-regex@^1.0.0: resolved "https://registry.yarnpkg.com/rgba-regex/-/rgba-regex-1.0.0.tgz#43374e2e2ca0968b0ef1523460b7d730ff22eeb3" integrity sha1-QzdOLiyglosO8VI0YLfXMP8i7rM= -rimraf@2, rimraf@2.6.2, rimraf@^2.2.8, rimraf@^2.5.2, rimraf@^2.5.4, rimraf@^2.6.0, rimraf@^2.6.1, rimraf@^2.6.2: +rimraf@2.6.2, rimraf@^2.2.8, rimraf@^2.5.2, rimraf@^2.5.4, rimraf@^2.6.0, rimraf@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.2.tgz#2ed8150d24a16ea8651e6d6ef0f47c4158ce7a36" integrity sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w== @@ -11081,6 +11388,13 @@ rimraf@2.6.3, rimraf@^2.6.3: dependencies: glob "^7.1.3" +rimraf@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.0.tgz#614176d4b3010b75e5c390eb0ee96f6dc0cebb9b" + integrity sha512-NDGVxTsjqfunkds7CqsOiEnxln4Bo7Nddl3XhS4pXg5OzwkLqJ971ZVAAnB+DDLnF76N+VnDEiBHaVV8I06SUg== + dependencies: + glob "^7.1.3" + rimraf@^2.7.1: version "2.7.1" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" @@ -11182,13 +11496,6 @@ rxjs-spy@^7.5.1: error-stack-parser "^2.0.1" stacktrace-gps "^3.0.2" -rxjs@6.3.3: - version "6.3.3" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.3.3.tgz#3c6a7fa420e844a81390fb1158a9ec614f4bad55" - integrity sha512-JTWmoY9tWCs7zvIk/CvRjhjGaOd+OVBM987mxFo+OW66cGpdKjZcpmc74ES1sB//7Kl/PAe8+wEakuhG4pcgOw== - dependencies: - tslib "^1.9.0" - rxjs@6.4.0: version "6.4.0" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.4.0.tgz#f3bb0fe7bda7fb69deac0c16f17b50b0b8790504" @@ -11196,7 +11503,14 @@ rxjs@6.4.0: dependencies: tslib "^1.9.0" -rxjs@^6.0.0, rxjs@^6.1.0: +rxjs@6.5.4, rxjs@^6.4.0: + version "6.5.4" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.4.tgz#e0777fe0d184cec7872df147f303572d414e211c" + integrity sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q== + dependencies: + tslib "^1.9.0" + +rxjs@^6.0.0: version "6.2.2" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.2.2.tgz#eb75fa3c186ff5289907d06483a77884586e1cf9" integrity sha512-0MI8+mkKAXZUF9vMrEoPnaoHkfzBPP4IGwUYRJhIRJF6/w3uByO1e91bEHn8zd43RdkTMKiooYKmwz7RH6zfOQ== @@ -11220,6 +11534,11 @@ safe-buffer@5.1.2, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, s resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== +safe-buffer@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519" + integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg== + safe-regex@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" @@ -11232,26 +11551,15 @@ safe-regex@^1.1.0: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -sass-graph@^2.2.4: - version "2.2.4" - resolved "https://registry.yarnpkg.com/sass-graph/-/sass-graph-2.2.4.tgz#13fbd63cd1caf0908b9fd93476ad43a51d1e0b49" - integrity sha1-E/vWPNHK8JCLn9k0dq1DpR0eC0k= +sass-loader@7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-7.2.0.tgz#e34115239309d15b2527cb62b5dfefb62a96ff7f" + integrity sha512-h8yUWaWtsbuIiOCgR9fd9c2lRXZ2uG+h8Dzg/AGNj+Hg/3TO8+BBAW9mEP+mh8ei+qBKqSJ0F1FLlYjNBc61OA== dependencies: - glob "^7.0.0" - lodash "^4.0.0" - scss-tokenizer "^0.2.3" - yargs "^7.0.0" - -sass-loader@7.1.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-7.1.0.tgz#16fd5138cb8b424bf8a759528a1972d72aad069d" - integrity sha512-+G+BKGglmZM2GUSfT9TLuEp6tzehHPjAMoRRItOojWIqIGPloVCMhNIQuG639eJ+y033PaGTSjLaTHts8Kw79w== - dependencies: - clone-deep "^2.0.1" + clone-deep "^4.0.1" loader-utils "^1.0.1" - lodash.tail "^4.1.1" neo-async "^2.5.0" - pify "^3.0.0" + pify "^4.0.1" semver "^5.5.0" sass-loader@7.3.1: @@ -11275,6 +11583,13 @@ sass-resources-loader@^2.0.0: glob "^7.1.1" loader-utils "^1.0.4" +sass@1.22.9: + version "1.22.9" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.22.9.tgz#41a2ed6038027f58be2bd5041293452a29c2cb84" + integrity sha512-FzU1X2V8DlnqabrL4u7OBwD2vcOzNMongEJEx3xMEhWY/v26FFR3aG0hyeu2T965sfR0E9ufJwmG+Qjz78vFPQ== + dependencies: + chokidar ">=2.0.0 <4.0.0" + saucelabs@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/saucelabs/-/saucelabs-1.5.0.tgz#9405a73c360d449b232839919a86c396d379fd9d" @@ -11287,7 +11602,7 @@ sax@0.5.x: resolved "https://registry.yarnpkg.com/sax/-/sax-0.5.8.tgz#d472db228eb331c2506b0e8c15524adb939d12c1" integrity sha1-1HLbIo6zMcJQaw6MFVJK25OdEsE= -sax@>=0.6.0, sax@^1.2.4, sax@~1.2.4: +sax@>=0.6.0, sax@~1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== @@ -11299,7 +11614,7 @@ schema-utils@^0.3.0: dependencies: ajv "^5.0.0" -schema-utils@^0.4.4, schema-utils@^0.4.5: +schema-utils@^0.4.5: version "0.4.7" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-0.4.7.tgz#ba74f597d2be2ea880131746ee17d0a093c68187" integrity sha512-v/iwU6wvwGK8HbU9yi3/nhGzP0yGSuhQMzL6ySiec1FSrZZDkhm4noOSWzrNFo/jEc+SJY6jRTwuwbSXJPDUnQ== @@ -11316,6 +11631,14 @@ schema-utils@^1.0.0: ajv-errors "^1.0.0" ajv-keywords "^3.1.0" +schema-utils@^2.0.0, schema-utils@^2.0.1: + version "2.6.4" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.6.4.tgz#a27efbf6e4e78689d91872ee3ccfa57d7bdd0f53" + integrity sha512-VNjcaUxVnEeun6B2fiiUDjXXBtD4ZSH7pdbfIu1pOFwgptDPLMo/z9jr4sUfsjFVPqDCEin/F7IYlq7/E6yDbQ== + dependencies: + ajv "^6.10.2" + ajv-keywords "^3.4.1" + schema-utils@^2.6.0, schema-utils@^2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.6.1.tgz#eb78f0b945c7bcfa2082b3565e8db3548011dc4f" @@ -11331,14 +11654,6 @@ script-ext-html-webpack-plugin@2.1.4: dependencies: debug "^4.1.1" -scss-tokenizer@^0.2.3: - version "0.2.3" - resolved "https://registry.yarnpkg.com/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz#8eb06db9a9723333824d3f5530641149847ce5d1" - integrity sha1-jrBtualyMzOCTT9VMGQRSYR85dE= - dependencies: - js-base64 "^2.1.8" - source-map "^0.4.2" - select-hose@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" @@ -11361,7 +11676,7 @@ selfsigned@^1.10.4: dependencies: node-forge "0.7.5" -selfsigned@^1.9.1: +selfsigned@^1.10.7: version "1.10.7" resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-1.10.7.tgz#da5819fd049d5574f28e88a9bcc6dbc6e6f3906b" integrity sha512-8M3wBCzeWIJnQfl43IKwOmC4H/RAp50S8DF60znzjW5GVqTcSe2vWclt7hmYVPkKPlHWOu5EaWOMZ2Y6W8ZXTA== @@ -11394,7 +11709,17 @@ semver-intersect@1.4.0: resolved "https://registry.yarnpkg.com/semver/-/semver-5.5.1.tgz#7dfdd8814bdb7cabc7be0fb1d734cfb66c940477" integrity sha512-PqpAxfrEhlSUWge8dwIp4tZnQ25DIOthpiaHNIthsjEFQD6EvqUKUDM7L8O2rShkFccYo1VjJR0coWfNkCubRw== -semver@5.6.0, semver@^5.0.1: +semver@6.3.0, semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" + integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== + +semver@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" + integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== + +semver@^5.0.1: version "5.6.0" resolved "https://registry.yarnpkg.com/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004" integrity sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg== @@ -11404,16 +11729,6 @@ semver@^5.4.1, semver@^5.5.0, semver@^5.6.0: resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== -semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" - integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== - -semver@~5.3.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" - integrity sha1-myzl094C0XxgEq0yaqa00M9U+U8= - send@0.16.1: version "0.16.1" resolved "https://registry.yarnpkg.com/send/-/send-0.16.1.tgz#a70e1ca21d1382c11d0d9f6231deb281080d7ab3" @@ -11452,12 +11767,12 @@ send@0.17.1: range-parser "~1.2.1" statuses "~1.5.0" -"serialize-javascript@>= 2.1.2", serialize-javascript@^1.4.0, serialize-javascript@^2.1.2: +"serialize-javascript@>= 2.1.2", serialize-javascript@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-2.1.2.tgz#ecec53b0e0317bdc95ef76ab7074b7384785fa61" integrity sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ== -serve-index@^1.7.2, serve-index@^1.9.1: +serve-index@^1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239" integrity sha1-03aNabHn2C5c4FD/9bRTvqEqkjk= @@ -11490,7 +11805,7 @@ serve-static@1.14.1: parseurl "~1.3.3" send "0.17.1" -set-blocking@^2.0.0, set-blocking@~2.0.0: +set-blocking@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= @@ -11535,15 +11850,6 @@ sha.js@^2.4.0, sha.js@^2.4.8: inherits "^2.0.1" safe-buffer "^5.0.1" -shallow-clone@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-1.0.0.tgz#4480cd06e882ef68b2ad88a3ea54832e2c48b571" - integrity sha512-oeXreoKR/SyNJtRJMAKPDSvd28OqEwG4eR/xc856cRGBII7gX9lvAqDxusPm0846z/w/hWYjI1NpKwJ00NHzRA== - dependencies: - is-extendable "^0.1.1" - kind-of "^5.0.0" - mixin-object "^2.0.1" - shallow-clone@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3" @@ -11582,15 +11888,6 @@ shelljs@^0.7.0: interpret "^1.0.0" rechoir "^0.6.2" -shelljs@^0.8.1: - version "0.8.3" - resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.3.tgz#a7f3319520ebf09ee81275b2368adb286659b097" - integrity sha512-fc0BKlAWiLpwZljmOvAOTE/gXawtCoNrP5oaY7KIaQbbyHeQVg01pSEuEGvGh3HEdBU4baCD7wQBwADmM/7f7A== - dependencies: - glob "^7.0.0" - interpret "^1.0.0" - rechoir "^0.6.2" - signal-exit@^3.0.0, signal-exit@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" @@ -11710,6 +12007,18 @@ sockjs-client@1.3.0: json3 "^3.3.2" url-parse "^1.4.3" +sockjs-client@1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.4.0.tgz#c9f2568e19c8fd8173b4997ea3420e0bb306c7d5" + integrity sha512-5zaLyO8/nri5cua0VtOrFXBPK1jbL4+1cebT/mmKA1E1ZXOvJrII75bPu0l0k843G/+iAbhEqzyKr0w/eCCj7g== + dependencies: + debug "^3.2.5" + eventsource "^1.0.7" + faye-websocket "~0.11.1" + inherits "^2.0.3" + json3 "^3.3.2" + url-parse "^1.4.3" + sockjs@0.3.19: version "0.3.19" resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.19.tgz#d976bbe800af7bd20ae08598d582393508993c0d" @@ -11734,6 +12043,13 @@ socks@~2.3.2: ip "1.1.5" smart-buffer "^4.1.0" +sort-keys@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad" + integrity sha1-RBttTTRnmPG05J6JIK37oOVD+a0= + dependencies: + is-plain-obj "^1.0.0" + sortablejs@1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/sortablejs/-/sortablejs-1.7.0.tgz#80a2b2370abd568e1cec8c271131ef30a904fa28" @@ -11768,10 +12084,10 @@ source-map-resolve@^0.5.0: source-map-url "^0.4.0" urix "^0.1.0" -source-map-support@0.5.10: - version "0.5.10" - resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.10.tgz#2214080bc9d51832511ee2bab96e3c2f9353120c" - integrity sha512-YfQ3tQFTK/yzlGJuX8pTwa4tifQj4QS2Mj7UegOu8jAz59MqIiMGPXxQhVQiIMNzayuUSF/jEuVnfFF5JqybmQ== +source-map-support@0.5.13: + version "0.5.13" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.13.tgz#31b24a9c2e73c2de85066c0feb7d44767ed52932" + integrity sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w== dependencies: buffer-from "^1.0.0" source-map "^0.6.0" @@ -11784,7 +12100,7 @@ source-map-support@^0.5.0, source-map-support@~0.5.6: buffer-from "^1.0.0" source-map "^0.6.0" -source-map-support@^0.5.5, source-map-support@~0.5.10, source-map-support@~0.5.12: +source-map-support@^0.5.5, source-map-support@~0.5.12: version "0.5.16" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.16.tgz#0ae069e7fe3ba7538c64c98515e35339eac5a042" integrity sha512-efyLRJDr68D9hBBNIPWFjhpFzURh+KJykQwvMyW5UiZzYwoF6l4YMMDIJJEyFWxWCqfyxLzz6tSfUFR+kXXsVQ== @@ -11826,14 +12142,7 @@ source-map@0.7.3: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== -source-map@^0.4.2, source-map@~0.4.1: - version "0.4.4" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.4.4.tgz#eba4f5da9c0dc999de68032d8b4f76173652036b" - integrity sha1-66T12pwNyZneaAMti092FzZSA2s= - dependencies: - amdefine ">=0.0.4" - -source-map@^0.5.1, source-map@^0.5.3, source-map@^0.5.6, source-map@^0.5.7: +source-map@^0.5.0, source-map@^0.5.1, source-map@^0.5.3, source-map@^0.5.6, source-map@^0.5.7: version "0.5.7" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= @@ -11850,6 +12159,13 @@ source-map@~0.2.0: dependencies: amdefine ">=0.0.4" +source-map@~0.4.1: + version "0.4.4" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.4.4.tgz#eba4f5da9c0dc999de68032d8b4f76173652036b" + integrity sha1-66T12pwNyZneaAMti092FzZSA2s= + dependencies: + amdefine ">=0.0.4" + sourcemap-codec@^1.4.4: version "1.4.6" resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.6.tgz#e30a74f0402bad09807640d39e971090a08ce1e9" @@ -11898,7 +12214,7 @@ spdy-transport@^3.0.0: readable-stream "^3.0.6" wbuf "^1.7.3" -spdy@^4.0.0: +spdy@^4.0.0, spdy@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/spdy/-/spdy-4.0.1.tgz#6f12ed1c5db7ea4f24ebb8b89ba58c87c08257f2" integrity sha512-HeZS3PBdMA+sZSu0qwpCxl3DeALD5ASx8pAX0jZdKXSpPWbQ6SYGnlg3BBmYLx5LtiZrmkAZfErCm2oECBcioA== @@ -11956,13 +12272,6 @@ sshpk@^1.7.0: jsbn "~0.1.0" tweetnacl "~0.14.0" -ssri@^5.2.4: - version "5.3.0" - resolved "https://registry.yarnpkg.com/ssri/-/ssri-5.3.0.tgz#ba3872c9c6d33a0704a7d71ff045e5ec48999d06" - integrity sha512-XRSIPqLij52MtgoQavH/x/dU1qVKtWUAAZeOHsR9c2Ddi4XerFy3mc1alf+dLJKl9EUIm/Ht+EowFkTUOA6GAQ== - dependencies: - safe-buffer "^5.1.1" - ssri@^6.0.0, ssri@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.1.tgz#2a3c41b28dd45b62b63676ecb74001265ae9edd8" @@ -12004,13 +12313,6 @@ static-extend@^0.1.1: define-property "^0.2.5" object-copy "^0.1.0" -stats-webpack-plugin@0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/stats-webpack-plugin/-/stats-webpack-plugin-0.7.0.tgz#ccffe9b745de8bbb155571e063f8263fc0e2bc06" - integrity sha512-NT0YGhwuQ0EOX+uPhhUcI6/+1Sq/pMzNuSCBVT4GbFl/ac6I/JZefBcjlECNfAb1t3GOx5dEj1Z7x0cAxeeVLQ== - dependencies: - lodash "^4.17.4" - "statuses@>= 1.3.1 < 2", "statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@~1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" @@ -12021,13 +12323,6 @@ statuses@~1.3.1: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.3.1.tgz#faf51b9eb74aaef3b3acf4ad5f61abf24cb7b93e" integrity sha1-+vUbnrdKrvOzrPStX2Gr8ky3uT4= -stdout-stream@^1.4.0: - version "1.4.1" - resolved "https://registry.yarnpkg.com/stdout-stream/-/stdout-stream-1.4.1.tgz#5ac174cdd5cd726104aa0c0b2bd83815d8d535de" - integrity sha512-j4emi03KXqJWcIeF8eIXkjMFN1Cmb8gUlDYGeBALLPo5qdyTfA9bOtl8m33lRoC+vFMkP3gl0WsDr6+gzxbbTA== - dependencies: - readable-stream "^2.0.1" - stream-browserify@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.1.tgz#66266ee5f9bdb9940a4e4514cafb43bb71e5c9db" @@ -12078,6 +12373,11 @@ streamroller@^1.0.6: fs-extra "^7.0.1" lodash "^4.17.14" +strict-uri-encode@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713" + integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM= + string-replace-loader@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/string-replace-loader/-/string-replace-loader-2.1.1.tgz#b72e7b57b6ef04efe615aff0ad989b5c14ca63d1" @@ -12086,7 +12386,7 @@ string-replace-loader@^2.1.1: loader-utils "^1.1.0" schema-utils "^0.4.5" -string-width@^1.0.1, string-width@^1.0.2: +string-width@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M= @@ -12095,7 +12395,7 @@ string-width@^1.0.1, string-width@^1.0.2: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" -"string-width@^1.0.2 || 2", string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1: +string-width@^2.0.0, string-width@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== @@ -12211,13 +12511,13 @@ strip-json-comments@^3.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.0.1.tgz#85713975a91fb87bf1b305cca77395e40d2a64a7" integrity sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw== -style-loader@0.23.1: - version "0.23.1" - resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-0.23.1.tgz#cb9154606f3e771ab6c4ab637026a1049174d925" - integrity sha512-XK+uv9kWwhZMZ1y7mysB+zoihsEj4wneFWAS5qoiLwzW0WzSqMrrsIy+a3zkQJq0ipFtBpX5W3MqyRIBF/WFGg== +style-loader@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-1.0.0.tgz#1d5296f9165e8e2c85d24eee0b7caf9ec8ca1f82" + integrity sha512-B0dOCFwv7/eY31a5PCieNwMgMhVGFe9w+rh7s/Bx8kfFkrth9zfTZquoYvdw8URgiqxObQKcpW51Ugz1HjfdZw== dependencies: - loader-utils "^1.1.0" - schema-utils "^1.0.0" + loader-utils "^1.2.3" + schema-utils "^2.0.1" stylehacks@^4.0.0: version "4.0.3" @@ -12282,7 +12582,7 @@ supports-color@^3.1.0: dependencies: has-flag "^1.0.0" -supports-color@^5.1.0, supports-color@^5.2.0, supports-color@^5.3.0, supports-color@^5.4.0: +supports-color@^5.2.0, supports-color@^5.3.0, supports-color@^5.4.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== @@ -12329,7 +12629,7 @@ tapable@^1.0.0: resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.0.0.tgz#cbb639d9002eed9c6b5975eb20598d7936f1f9f2" integrity sha512-dQRhbNQkRnaqauC7WqSJ21EEksgT0fYZX2lqXzGkpo8JNig9zGZTYoMGvyI2nWmXlE2VSVXVDu7wLVGu/mQEsg== -tapable@^1.1.0, tapable@^1.1.3: +tapable@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA== @@ -12345,28 +12645,6 @@ tar-stream@^2.1.0: inherits "^2.0.3" readable-stream "^3.1.1" -tar@^2.0.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/tar/-/tar-2.2.1.tgz#8e4d2a256c0e2185c6b18ad694aec968b83cb1d1" - integrity sha1-jk0qJWwOIYXGsYrWlK7JaLg8sdE= - dependencies: - block-stream "*" - fstream "^1.0.2" - inherits "2" - -tar@^4: - version "4.4.6" - resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.6.tgz#63110f09c00b4e60ac8bcfe1bf3c8660235fbc9b" - integrity sha512-tMkTnh9EdzxyfW+6GK6fCahagXsnYk6kE6S9Gr9pjVdys769+laCTbodXDhPAjzVtEBazRgP0gYqOjnk9dQzLg== - dependencies: - chownr "^1.0.1" - fs-minipass "^1.2.5" - minipass "^2.3.3" - minizlib "^1.1.0" - mkdirp "^0.5.0" - safe-buffer "^5.1.2" - yallist "^3.0.2" - tar@^4.4.8: version "4.4.13" resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.13.tgz#43b364bc52888d555298637b10d60790254ab525" @@ -12387,21 +12665,7 @@ term-size@^1.2.0: dependencies: execa "^0.7.0" -terser-webpack-plugin@1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-1.2.2.tgz#9bff3a891ad614855a7dde0d707f7db5a927e3d9" - integrity sha512-1DMkTk286BzmfylAvLXwpJrI7dWa5BnFmscV/2dCr8+c56egFcbaeFAl7+sujAjdmpLam21XRdhA4oifLyiWWg== - dependencies: - cacache "^11.0.2" - find-cache-dir "^2.0.0" - schema-utils "^1.0.0" - serialize-javascript "^1.4.0" - source-map "^0.6.1" - terser "^3.16.1" - webpack-sources "^1.1.0" - worker-farm "^1.5.2" - -terser-webpack-plugin@^1.1.0, terser-webpack-plugin@^1.4.3: +terser-webpack-plugin@1.4.3, terser-webpack-plugin@^1.4.1, terser-webpack-plugin@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-1.4.3.tgz#5ecaf2dbdc5fb99745fd06791f46fc9ddb1c9a7c" integrity sha512-QMxecFz/gHQwteWwSo5nTc6UaICqN1bMedC5sMtUc7y3Ha3Q8y6ZO0iCR8pq4RJC8Hjf0FEPEHZqcMB/+DFCrA== @@ -12430,14 +12694,14 @@ terser-webpack-plugin@^2.3.1: terser "^4.4.3" webpack-sources "^1.4.3" -terser@^3.16.1: - version "3.17.0" - resolved "https://registry.yarnpkg.com/terser/-/terser-3.17.0.tgz#f88ffbeda0deb5637f9d24b0da66f4e15ab10cb2" - integrity sha512-/FQzzPJmCpjAH9Xvk2paiWrFq+5M6aVOf+2KRbwhByISDX/EujxsK+BAvrhb6H+2rtrLCHK9N01wO014vrIwVQ== +terser@4.6.3: + version "4.6.3" + resolved "https://registry.yarnpkg.com/terser/-/terser-4.6.3.tgz#e33aa42461ced5238d352d2df2a67f21921f8d87" + integrity sha512-Lw+ieAXmY69d09IIc/yqeBqXpEQIpDGZqT34ui1QWXIUpR2RjbqEkT8X7Lgex19hslSqcWM5iMN2kM11eMsESQ== dependencies: - commander "^2.19.0" + commander "^2.20.0" source-map "~0.6.1" - source-map-support "~0.5.10" + source-map-support "~0.5.12" terser@^3.8.2: version "3.8.2" @@ -12562,6 +12826,11 @@ to-fast-properties@^1.0.3: resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47" integrity sha1-uDVx+k2MJbguIxsG46MFXeTKGkc= +to-fast-properties@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" + integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4= + to-object-path@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af" @@ -12577,6 +12846,13 @@ to-regex-range@^2.1.0: is-number "^3.0.0" repeat-string "^1.6.1" +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + to-regex@^3.0.1, to-regex@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce" @@ -12619,10 +12895,10 @@ tough-cookie@~2.4.3: psl "^1.1.24" punycode "^1.4.1" -tree-kill@1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.1.tgz#5398f374e2f292b9dcc7b2e71e30a5c3bb6c743a" - integrity sha512-4hjqbObwlh2dLyW4tcz0Ymw0ggoaVDMveUB9w8kFSQScdRLo0gxO9J7WFcUBo+W3C1TLdFIEwNOWebgZZ0RH9Q== +tree-kill@1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" + integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== trim-newlines@^1.0.0: version "1.0.0" @@ -12634,13 +12910,6 @@ trim-right@^1.0.1: resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003" integrity sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM= -"true-case-path@^1.0.2": - version "1.0.3" - resolved "https://registry.yarnpkg.com/true-case-path/-/true-case-path-1.0.3.tgz#f813b5a8c86b40da59606722b144e3225799f47d" - integrity sha512-m6s2OdQe5wgpFMC+pAJ+q9djG82O2jcHPOI6RNg1yy9rCYR+WD6Nbpl32fDpfC56nirdRy+opFa/Vk7HYhqaew== - dependencies: - glob "^7.1.2" - tryer@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8" @@ -12693,12 +12962,12 @@ tsconfig@^7.0.0: strip-bom "^3.0.0" strip-json-comments "^2.0.0" -tslib@^1.7.1: +tslib@1.10.0, tslib@^1.7.1, tslib@^1.9.0: version "1.10.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a" integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ== -tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0: +tslib@^1.8.0, tslib@^1.8.1: version "1.9.3" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286" integrity sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ== @@ -12828,15 +13097,10 @@ typescript@2.4.1: resolved "https://registry.yarnpkg.com/typescript/-/typescript-2.4.1.tgz#c3ccb16ddaa0b2314de031e7e6fee89e5ba346bc" integrity sha1-w8yxbdqgsjFN4DHn5v7onlujRrw= -typescript@3.1.6: - version "3.1.6" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.1.6.tgz#b6543a83cfc8c2befb3f4c8fba6896f5b0c9be68" - integrity sha512-tDMYfVtvpb96msS1lDX9MEdHrW4yOuZ4Kdc4Him9oU796XldPYF/t2+uKoX0BBa0hXXwDlqYQbXY5Rzjzc5hBA== - -typescript@3.2.4: - version "3.2.4" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.2.4.tgz#c585cb952912263d915b462726ce244ba510ef3d" - integrity sha512-0RNDbSdEokBeEAkgNbxJ+BLwSManFy9TeXz8uW+48j/xhEXv1ePME60olyzw2XzUqUBNAYFeJadIqAgNqIACwg== +typescript@3.5.3: + version "3.5.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.5.3.tgz#c830f657f93f1ea846819e929092f5fe5983e977" + integrity sha512-ACzBtm/PhXBDId6a6sDJfroT2pOWt/oOnk4/dElG5G33ZL776N3Y6/6bKZJBFpd+b05F3Ct9qDjMeJmRWtE2/g== "typescript@>=2.6.2 <2.10", typescript@^2.5.0: version "2.9.2" @@ -12878,6 +13142,29 @@ undefsafe@^2.0.2: dependencies: debug "^2.2.0" +unicode-canonical-property-names-ecmascript@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818" + integrity sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ== + +unicode-match-property-ecmascript@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-1.0.4.tgz#8ed2a32569961bce9227d09cd3ffbb8fed5f020c" + integrity sha512-L4Qoh15vTfntsn4P1zqnHulG0LdXgjSO035fEpdtp6YxXhMT51Q6vgM5lYdG/5X3MjS+k/Y9Xw4SFCY9IkR0rg== + dependencies: + unicode-canonical-property-names-ecmascript "^1.0.4" + unicode-property-aliases-ecmascript "^1.0.4" + +unicode-match-property-value-ecmascript@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-1.1.0.tgz#5b4b426e08d13a80365e0d657ac7a6c1ec46a277" + integrity sha512-hDTHvaBk3RmFzvSl0UVrUmC3PuW9wKVnpoUDYH0JDkSIovzw+J5viQmeYHxVSBptubnr7PbH2e0fnpDRQnQl5g== + +unicode-property-aliases-ecmascript@^1.0.4: + version "1.0.5" + resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.0.5.tgz#a9cc6cc7ce63a0a3023fc99e341b94431d405a57" + integrity sha512-L5RAqCfXqAwR3RriF8pM0lU0w4Ryf/GgzONwi6KnL1taJQa7x1TCxdJnILX59WIGOwR57IVxn7Nej0fz1Ny6fw== + union-value@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.0.tgz#5c71c34cb5bad5dcebe3ea0cd08207ba5aa1aea4" @@ -12905,13 +13192,6 @@ uniqs@^2.0.0: resolved "https://registry.yarnpkg.com/uniqs/-/uniqs-2.0.0.tgz#ffede4b36b25290696e6e165d4a59edb998e6b02" integrity sha1-/+3ks2slKQaW5uFl1KWe25mOawI= -unique-filename@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.0.tgz#d05f2fe4032560871f30e93cbe735eea201514f3" - integrity sha1-0F8v5AMlYIcfMOk8vnNe6iAVFPM= - dependencies: - unique-slug "^2.0.0" - unique-filename@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230" @@ -12941,6 +13221,15 @@ units-css@^0.4.0: isnumeric "^0.2.0" viewport-dimensions "^0.2.0" +universal-analytics@^0.4.20: + version "0.4.20" + resolved "https://registry.yarnpkg.com/universal-analytics/-/universal-analytics-0.4.20.tgz#d6b64e5312bf74f7c368e3024a922135dbf24b03" + integrity sha512-gE91dtMvNkjO+kWsPstHRtSwHXz0l2axqptGYp5ceg4MsuurloM0PU3pdOfpb5zBXUvyjT4PwhWK2m39uczZuw== + dependencies: + debug "^3.0.0" + request "^2.88.0" + uuid "^3.0.0" + universalify@^0.1.0: version "0.1.2" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" @@ -13078,6 +13367,13 @@ util-deprecate@^1.0.1, util-deprecate@~1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= +util-promisify@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/util-promisify/-/util-promisify-2.1.0.tgz#3c2236476c4d32c5ff3c47002add7c13b9a82a53" + integrity sha1-PCI2R2xNMsX/PEcAKt18E7moKlM= + dependencies: + object.getownpropertydescriptors "^2.0.3" + util.promisify@1.0.0, util.promisify@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/util.promisify/-/util.promisify-1.0.0.tgz#440f7165a459c9a16dc145eb8e72f35687097030" @@ -13093,13 +13389,6 @@ util@0.10.3: dependencies: inherits "2.0.1" -util@^0.10.3: - version "0.10.4" - resolved "https://registry.yarnpkg.com/util/-/util-0.10.4.tgz#3aa0125bfe668a4672de58857d3ace27ecb76901" - integrity sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A== - dependencies: - inherits "2.0.3" - util@^0.11.0: version "0.11.1" resolved "https://registry.yarnpkg.com/util/-/util-0.11.1.tgz#3236733720ec64bb27f6e26f421aaa2e1b588d61" @@ -13127,6 +13416,11 @@ uuid@^2.0.1: resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.3.tgz#67e2e863797215530dff318e5bf9dcebfd47b21a" integrity sha1-Z+LoY3lyFVMN/zGOW/nc6/1Hsho= +uuid@^3.0.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" + integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== + uuid@^3.0.1, uuid@^3.1.0, uuid@^3.2.1, uuid@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" @@ -13207,13 +13501,6 @@ vlq@^0.2.2: resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26" integrity sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow== -vm-browserify@0.0.4: - version "0.0.4" - resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-0.0.4.tgz#5d7ea45bbef9e4a6ff65f95438e0a87c357d5a73" - integrity sha1-XX6kW7755Kb/ZflUOOCofDV9WnM= - dependencies: - indexof "0.0.1" - vm-browserify@^1.0.1: version "1.1.2" resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0" @@ -13224,7 +13511,7 @@ void-elements@^2.0.0: resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec" integrity sha1-wGavtYK7HLQSjWDqkjkulNXp2+w= -watchpack@^1.5.0, watchpack@^1.6.0: +watchpack@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.6.0.tgz#4bc12c2ebe8aa277a71f1d3f14d685c7b446cd00" integrity sha512-i6dHe3EyLjMmDlU1/bGQpEw25XSjkJULPuAVKCbNRefQVq48yXKUpwg538F7AZTf9kyr57zj++pQFltUa5H7yA== @@ -13359,24 +13646,15 @@ webpack-dev-middleware@3.2.0: url-join "^4.0.0" webpack-log "^2.0.0" -webpack-dev-middleware@3.4.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-3.4.0.tgz#1132fecc9026fd90f0ecedac5cbff75d1fb45890" - integrity sha512-Q9Iyc0X9dP9bAsYskAVJ/hmIZZQwf/3Sy4xCAZgL5cUkjZmUZLt4l5HpbST/Pdgjn3u6pE7u5OdGd1apgzRujA== +webpack-dev-middleware@3.7.2, webpack-dev-middleware@^3.7.2: + version "3.7.2" + resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-3.7.2.tgz#0019c3db716e3fa5cecbf64f2ab88a74bab331f3" + integrity sha512-1xC42LxbYoqLNAhV6YzTYacicgMZQTqRd27Sim9wn5hJrX3I5nxYy1SxSd4+gjUFsz1dQFj+yEe6zEVmSkeJjw== dependencies: - memory-fs "~0.4.1" - mime "^2.3.1" - range-parser "^1.0.3" - webpack-log "^2.0.0" - -webpack-dev-middleware@3.5.1: - version "3.5.1" - resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-3.5.1.tgz#9265b7742ef50f54f54c1d9af022fc17c1be9b88" - integrity sha512-4dwCh/AyMOYAybggUr8fiCkRnjVDp+Cqlr9c+aaNB3GJYgRGYQWJ1YX/WAKUNA9dPNHZ6QSN2lYDKqjKSI8Vqw== - dependencies: - memory-fs "~0.4.1" - mime "^2.3.1" - range-parser "^1.0.3" + memory-fs "^0.4.1" + mime "^2.4.4" + mkdirp "^0.5.1" + range-parser "^1.2.1" webpack-log "^2.0.0" webpack-dev-middleware@^2.0.6: @@ -13402,41 +13680,44 @@ webpack-dev-middleware@^3.7.0: range-parser "^1.2.1" webpack-log "^2.0.0" -webpack-dev-server@3.1.14: - version "3.1.14" - resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-3.1.14.tgz#60fb229b997fc5a0a1fc6237421030180959d469" - integrity sha512-mGXDgz5SlTxcF3hUpfC8hrQ11yhAttuUQWf1Wmb+6zo3x6rb7b9mIfuQvAPLdfDRCGRGvakBWHdHOa0I9p/EVQ== +webpack-dev-server@3.9.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-3.9.0.tgz#27c3b5d0f6b6677c4304465ac817623c8b27b89c" + integrity sha512-E6uQ4kRrTX9URN9s/lIbqTAztwEPdvzVrcmHE8EQ9YnuT9J8Es5Wrd8n9BKg1a0oZ5EgEke/EQFgUsp18dSTBw== dependencies: ansi-html "0.0.7" bonjour "^3.5.0" - chokidar "^2.0.0" - compression "^1.5.2" - connect-history-api-fallback "^1.3.0" - debug "^3.1.0" - del "^3.0.0" - express "^4.16.2" - html-entities "^1.2.0" - http-proxy-middleware "~0.18.0" + chokidar "^2.1.8" + compression "^1.7.4" + connect-history-api-fallback "^1.6.0" + debug "^4.1.1" + del "^4.1.1" + express "^4.17.1" + html-entities "^1.2.1" + http-proxy-middleware "0.19.1" import-local "^2.0.0" - internal-ip "^3.0.1" + internal-ip "^4.3.0" ip "^1.1.5" - killable "^1.0.0" - loglevel "^1.4.1" - opn "^5.1.0" - portfinder "^1.0.9" + is-absolute-url "^3.0.3" + killable "^1.0.1" + loglevel "^1.6.4" + opn "^5.5.0" + p-retry "^3.0.1" + portfinder "^1.0.25" schema-utils "^1.0.0" - selfsigned "^1.9.1" - semver "^5.6.0" - serve-index "^1.7.2" + selfsigned "^1.10.7" + semver "^6.3.0" + serve-index "^1.9.1" sockjs "0.3.19" - sockjs-client "1.3.0" - spdy "^4.0.0" - strip-ansi "^3.0.0" - supports-color "^5.1.0" + sockjs-client "1.4.0" + spdy "^4.0.1" + strip-ansi "^3.0.1" + supports-color "^6.1.0" url "^0.11.0" - webpack-dev-middleware "3.4.0" + webpack-dev-middleware "^3.7.2" webpack-log "^2.0.0" - yargs "12.0.2" + ws "^6.2.1" + yargs "12.0.5" webpack-dev-server@^3.1.11: version "3.7.2" @@ -13519,10 +13800,10 @@ webpack-node-externals@1.7.2: resolved "https://registry.yarnpkg.com/webpack-node-externals/-/webpack-node-externals-1.7.2.tgz#6e1ee79ac67c070402ba700ef033a9b8d52ac4e3" integrity sha512-ajerHZ+BJKeCLviLUUmnyd5B4RavLF76uv3cs6KNuO8W+HuQaEs0y0L7o40NQxdPy5w0pcv8Ew7yPUAQG0UdCg== -webpack-sources@1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.3.0.tgz#2a28dcb9f1f45fe960d8f1493252b5ee6530fa85" - integrity sha512-OiVgSrbGu7NEnEvQJJgdSFPl2qWKkWq5lHMhgiToIiN9w34EBnjYzSYs+VbL5KoYiLNtFFa7BZIKxRED3I32pA== +webpack-sources@1.4.3, webpack-sources@^1.2.0, webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933" + integrity sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ== dependencies: source-list-map "^2.0.0" source-map "~0.6.1" @@ -13535,14 +13816,6 @@ webpack-sources@^1.0.1, webpack-sources@^1.1.0: source-list-map "^2.0.0" source-map "~0.6.1" -webpack-sources@^1.2.0, webpack-sources@^1.3.0, webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3: - version "1.4.3" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933" - integrity sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ== - dependencies: - source-list-map "^2.0.0" - source-map "~0.6.1" - webpack-subresource-integrity@1.1.0-rc.6: version "1.1.0-rc.6" resolved "https://registry.yarnpkg.com/webpack-subresource-integrity/-/webpack-subresource-integrity-1.1.0-rc.6.tgz#37f6f1264e1eb378e41465a98da80fad76ab8886" @@ -13550,35 +13823,34 @@ webpack-subresource-integrity@1.1.0-rc.6: dependencies: webpack-core "^0.6.8" -webpack@4.29.0: - version "4.29.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.29.0.tgz#f2cfef83f7ae404ba889ff5d43efd285ca26e750" - integrity sha512-pxdGG0keDBtamE1mNvT5zyBdx+7wkh6mh7uzMOo/uRQ/fhsdj5FXkh/j5mapzs060forql1oXqXN9HJGju+y7w== +webpack@4.39.2: + version "4.39.2" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.39.2.tgz#c9aa5c1776d7c309d1b3911764f0288c8c2816aa" + integrity sha512-AKgTfz3xPSsEibH00JfZ9sHXGUwIQ6eZ9tLN8+VLzachk1Cw2LVmy+4R7ZiwTa9cZZ15tzySjeMui/UnSCAZhA== dependencies: - "@webassemblyjs/ast" "1.7.11" - "@webassemblyjs/helper-module-context" "1.7.11" - "@webassemblyjs/wasm-edit" "1.7.11" - "@webassemblyjs/wasm-parser" "1.7.11" - acorn "^6.0.5" - acorn-dynamic-import "^4.0.0" - ajv "^6.1.0" - ajv-keywords "^3.1.0" - chrome-trace-event "^1.0.0" + "@webassemblyjs/ast" "1.8.5" + "@webassemblyjs/helper-module-context" "1.8.5" + "@webassemblyjs/wasm-edit" "1.8.5" + "@webassemblyjs/wasm-parser" "1.8.5" + acorn "^6.2.1" + ajv "^6.10.2" + ajv-keywords "^3.4.1" + chrome-trace-event "^1.0.2" enhanced-resolve "^4.1.0" - eslint-scope "^4.0.0" + eslint-scope "^4.0.3" json-parse-better-errors "^1.0.2" - loader-runner "^2.3.0" - loader-utils "^1.1.0" - memory-fs "~0.4.1" - micromatch "^3.1.8" - mkdirp "~0.5.0" - neo-async "^2.5.0" - node-libs-browser "^2.0.0" - schema-utils "^0.4.4" - tapable "^1.1.0" - terser-webpack-plugin "^1.1.0" - watchpack "^1.5.0" - webpack-sources "^1.3.0" + loader-runner "^2.4.0" + loader-utils "^1.2.3" + memory-fs "^0.4.1" + micromatch "^3.1.10" + mkdirp "^0.5.1" + neo-async "^2.6.1" + node-libs-browser "^2.2.1" + schema-utils "^1.0.0" + tapable "^1.1.3" + terser-webpack-plugin "^1.4.1" + watchpack "^1.6.0" + webpack-sources "^1.4.1" webpack@^4.29.6: version "4.41.3" @@ -13627,30 +13899,18 @@ when@~3.6.x: resolved "https://registry.yarnpkg.com/when/-/when-3.6.4.tgz#473b517ec159e2b85005497a13983f095412e34e" integrity sha1-RztRfsFZ4rhQBUl6E5g/CVQS404= -which-module@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/which-module/-/which-module-1.0.0.tgz#bba63ca861948994ff307736089e3b96026c2a4f" - integrity sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8= - which-module@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho= -which@1, which@^1.1.1, which@^1.2.1, which@^1.2.10, which@^1.2.14, which@^1.2.9, which@^1.3.1: +which@^1.1.1, which@^1.2.1, which@^1.2.10, which@^1.2.14, which@^1.2.9, which@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== dependencies: isexe "^2.0.0" -wide-align@^1.1.0: - version "1.1.3" - resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" - integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== - dependencies: - string-width "^1.0.2 || 2" - widest-line@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-2.0.0.tgz#0142a4e8a243f8882c0233aa0e0281aa76152273" @@ -13673,13 +13933,6 @@ wordwrap@~0.0.2: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107" integrity sha1-o9XabNXAvAAI03I0u68b7WMFkQc= -worker-farm@^1.5.2: - version "1.6.0" - resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.6.0.tgz#aecc405976fab5a95526180846f0dba288f3a4a0" - integrity sha512-6w+3tHbM87WnSWnENBUvA2pxJPLhQUg5LKwUQHq3r+XPhIM+Gh2R5ycbwPCyuGbNg+lPgdcnQUhuC02kJCvffQ== - dependencies: - errno "~0.1.7" - worker-farm@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.7.0.tgz#26a94c5391bbca926152002f69b84a4bf772e5a8" @@ -13687,6 +13940,13 @@ worker-farm@^1.7.0: dependencies: errno "~0.1.7" +worker-plugin@3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/worker-plugin/-/worker-plugin-3.2.0.tgz#ddae9f161b76fcbaacf8f54ecd037844584e43e7" + integrity sha512-W5nRkw7+HlbsEt3qRP6MczwDDISjiRj2GYt9+bpe8A2La00TmJdwzG5bpdMXhRt1qcWmwAvl1TiKaHRa+XDS9Q== + dependencies: + loader-utils "^1.1.0" + wrap-ansi@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" @@ -13725,7 +13985,7 @@ write@1.0.3: dependencies: mkdirp "^0.5.1" -ws@^6.0.0: +ws@^6.0.0, ws@^6.2.1: version "6.2.1" resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.1.tgz#442fdf0a47ed64f59b6a5d8ff130f4748ed524fb" integrity sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA== @@ -13769,21 +14029,11 @@ xmlhttprequest-ssl@~1.5.4: resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz#c2876b06168aadc40e57d97e81191ac8f4398b3e" integrity sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4= -xregexp@4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/xregexp/-/xregexp-4.0.0.tgz#e698189de49dd2a18cc5687b05e17c8e43943020" - integrity sha512-PHyM+sQouu7xspQQwELlGwwd05mXUFqwFYfqPO0cC7x4fxyHnnuetmQr6CjJiafIDoH4MogHb9dOoJzR/Y4rFg== - xtend@^4.0.0, xtend@~4.0.0, xtend@~4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" integrity sha1-pcbVMr5lbiPbgg77lDofBJmNY68= -y18n@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" - integrity sha1-bRX7qITAhnnA136I53WegR4H+kE= - "y18n@^3.2.1 || ^4.0.0", y18n@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" @@ -13809,13 +14059,6 @@ yallist@^4.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== -yargs-parser@^10.1.0: - version "10.1.0" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-10.1.0.tgz#7202265b89f7e9e9f2e5765e0fe735a905edbaa8" - integrity sha512-VCIyR1wJoEBZUqk5PA+oOBF6ypbwh5aNB3I50guxAL/quggdfs4TtNHQrSazFA3fYZ+tEqfs0zIGlv0c/rgjbQ== - dependencies: - camelcase "^4.1.0" - yargs-parser@^11.1.1: version "11.1.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-11.1.1.tgz#879a0865973bca9f6bab5cbdf3b1c67ec7d3bcf4" @@ -13824,7 +14067,7 @@ yargs-parser@^11.1.1: camelcase "^5.0.0" decamelize "^1.2.0" -yargs-parser@^13.1.0, yargs-parser@^13.1.1: +yargs-parser@^13.0.0, yargs-parser@^13.1.0, yargs-parser@^13.1.1: version "13.1.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.1.tgz#d26058532aa06d365fe091f6a1fc06b2f7e5eca0" integrity sha512-oVAVsHz6uFrg3XQheFII8ESO2ssAf9luWuAd6Wexsu4F3OtIW0o8IribPXYrD4WC24LWtPrJlGy87y5udK+dxQ== @@ -13832,38 +14075,6 @@ yargs-parser@^13.1.0, yargs-parser@^13.1.1: camelcase "^5.0.0" decamelize "^1.2.0" -yargs-parser@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-5.0.0.tgz#275ecf0d7ffe05c77e64e7c86e4cd94bf0e1228a" - integrity sha1-J17PDX/+Bcd+ZOfIbkzZS/DhIoo= - dependencies: - camelcase "^3.0.0" - -yargs-parser@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-7.0.0.tgz#8d0ac42f16ea55debd332caf4c4038b3e3f5dfd9" - integrity sha1-jQrELxbqVd69MyyvTEA4s+P139k= - dependencies: - camelcase "^4.1.0" - -yargs@12.0.2: - version "12.0.2" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-12.0.2.tgz#fe58234369392af33ecbef53819171eff0f5aadc" - integrity sha512-e7SkEx6N6SIZ5c5H22RTZae61qtn3PYUE8JYbBFlK9sYmh3DMQ6E5ygtaG/2BW0JZi4WGgTR2IV5ChqlqrDGVQ== - dependencies: - cliui "^4.0.0" - decamelize "^2.0.0" - find-up "^3.0.0" - get-caller-file "^1.0.1" - os-locale "^3.0.0" - require-directory "^2.1.1" - require-main-filename "^1.0.1" - set-blocking "^2.0.0" - string-width "^2.0.0" - which-module "^2.0.0" - y18n "^3.2.1 || ^4.0.0" - yargs-parser "^10.1.0" - yargs@12.0.5, yargs@^12.0.1: version "12.0.5" resolved "https://registry.yarnpkg.com/yargs/-/yargs-12.0.5.tgz#05f5997b609647b64f66b81e3b4b10a368e7ad13" @@ -13882,6 +14093,23 @@ yargs@12.0.5, yargs@^12.0.1: y18n "^3.2.1 || ^4.0.0" yargs-parser "^11.1.1" +yargs@13.1.0: + version "13.1.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.1.0.tgz#b2729ce4bfc0c584939719514099d8a916ad2301" + integrity sha512-1UhJbXfzHiPqkfXNHYhiz79qM/kZqjTE8yGlEjZa85Q+3+OwcV6NRkV7XOV1W2Eom2bzILeUn55pQYffjVOLAg== + dependencies: + cliui "^4.0.0" + find-up "^3.0.0" + get-caller-file "^2.0.1" + os-locale "^3.1.0" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^3.0.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^13.0.0" + yargs@13.2.4: version "13.2.4" resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.2.4.tgz#0b562b794016eb9651b98bd37acf364aa5d6dc83" @@ -13899,25 +14127,6 @@ yargs@13.2.4: y18n "^4.0.0" yargs-parser "^13.1.0" -yargs@9.0.1: - version "9.0.1" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-9.0.1.tgz#52acc23feecac34042078ee78c0c007f5085db4c" - integrity sha1-UqzCP+7Kw0BCB47njAwAf1CF20w= - dependencies: - camelcase "^4.1.0" - cliui "^3.2.0" - decamelize "^1.1.1" - get-caller-file "^1.0.1" - os-locale "^2.0.0" - read-pkg-up "^2.0.0" - require-directory "^2.1.1" - require-main-filename "^1.0.1" - set-blocking "^2.0.0" - string-width "^2.0.0" - which-module "^2.0.0" - y18n "^3.2.1" - yargs-parser "^7.0.0" - yargs@^13.2.4: version "13.3.0" resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.0.tgz#4c657a55e07e5f2cf947f8a366567c04a0dedc83" @@ -13934,25 +14143,6 @@ yargs@^13.2.4: y18n "^4.0.0" yargs-parser "^13.1.1" -yargs@^7.0.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-7.1.0.tgz#6ba318eb16961727f5d284f8ea003e8d6154d0c8" - integrity sha1-a6MY6xaWFyf10oT46gA+jWFU0Mg= - dependencies: - camelcase "^3.0.0" - cliui "^3.2.0" - decamelize "^1.1.1" - get-caller-file "^1.0.1" - os-locale "^1.4.0" - read-pkg-up "^1.0.1" - require-directory "^2.1.1" - require-main-filename "^1.0.1" - set-blocking "^2.0.0" - string-width "^1.0.2" - which-module "^1.0.0" - y18n "^3.2.1" - yargs-parser "^5.0.0" - yauzl@2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.4.1.tgz#9528f442dab1b2284e58b4379bb194e22e0c4005" @@ -13979,7 +14169,7 @@ zip-stream@^2.1.2: compress-commons "^2.1.1" readable-stream "^3.4.0" -zone.js@^0.8.29: - version "0.8.29" - resolved "https://registry.yarnpkg.com/zone.js/-/zone.js-0.8.29.tgz#8dce92aa0dd553b50bc5bfbb90af9986ad845a12" - integrity sha512-mla2acNCMkWXBD+c+yeUrBUrzOxYMNFdQ6FGfigGGtEVBPJx07BQeJekjt9DmH1FtZek4E9rE1eRR9qQpxACOQ== +zone.js@^0.9.1: + version "0.9.1" + resolved "https://registry.yarnpkg.com/zone.js/-/zone.js-0.9.1.tgz#e37c6e5c54c13fae4de26b5ffe8d8e9212da6d9b" + integrity sha512-GkPiJL8jifSrKReKaTZ5jkhrMEgXbXYC+IPo1iquBjayRa0q86w3Dipjn8b415jpitMExe9lV8iTsv8tk3DGag==