diff --git a/README.md b/README.md index 78d7816f65..5d8a323461 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ DSPACE_REST_SSL # Whether the angular REST uses SSL [true/false] The same settings can also be overwritten by setting system environment variables instead, E.g.: ```bash -export DSPACE_HOST=https://dspace7.4science.cloud/server +export DSPACE_HOST=dspace7.4science.cloud ``` The priority works as follows: **environment variable** overrides **variable in `.env` file** overrides **`environment.(prod, dev or test).ts`** overrides **`environment.common.ts`** diff --git a/package.json b/package.json index 36462cc724..21a89400bf 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "express": "4.16.2", "fast-json-patch": "^2.0.7", "file-saver": "^1.3.8", + "filesize": "^6.1.0", "font-awesome": "4.7.0", "https": "1.0.0", "js-cookie": "2.2.0", @@ -100,7 +101,7 @@ "moment": "^2.22.1", "morgan": "^1.9.1", "ng-mocks": "^8.1.0", - "ng2-file-upload": "1.2.1", + "ng2-file-upload": "1.4.0", "ng2-nouislider": "^1.8.2", "ngx-bootstrap": "^5.3.2", "ngx-infinite-scroll": "6.0.1", diff --git a/src/app/+admin/admin-registries/admin-registries-routing.module.ts b/src/app/+admin/admin-registries/admin-registries-routing.module.ts index afdc46bf17..8833b307b9 100644 --- a/src/app/+admin/admin-registries/admin-registries-routing.module.ts +++ b/src/app/+admin/admin-registries/admin-registries-routing.module.ts @@ -4,6 +4,7 @@ import { NgModule } from '@angular/core'; import { MetadataSchemaComponent } from './metadata-schema/metadata-schema.component'; import { URLCombiner } from '../../core/url-combiner/url-combiner'; import { getRegistriesModulePath } from '../admin-routing.module'; +import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver'; const BITSTREAMFORMATS_MODULE_PATH = 'bitstream-formats'; @@ -14,16 +15,28 @@ export function getBitstreamFormatsModulePath() { @NgModule({ imports: [ RouterModule.forChild([ - {path: 'metadata', component: MetadataRegistryComponent, data: {title: 'admin.registries.metadata.title'}}, { - path: 'metadata/:schemaName', - component: MetadataSchemaComponent, - data: {title: 'admin.registries.schema.title'} + path: 'metadata', + resolve: { breadcrumb: I18nBreadcrumbResolver }, + data: {title: 'admin.registries.metadata.title', breadcrumbKey: 'admin.registries.metadata'}, + children: [ + { + path: '', + component: MetadataRegistryComponent + }, + { + path: ':schemaName', + resolve: { breadcrumb: I18nBreadcrumbResolver }, + component: MetadataSchemaComponent, + data: {title: 'admin.registries.schema.title', breadcrumbKey: 'admin.registries.schema'} + } + ] }, { path: BITSTREAMFORMATS_MODULE_PATH, + resolve: { breadcrumb: I18nBreadcrumbResolver }, loadChildren: './bitstream-formats/bitstream-formats.module#BitstreamFormatsModule', - data: {title: 'admin.registries.bitstream-formats.title'} + data: {title: 'admin.registries.bitstream-formats.title', breadcrumbKey: 'admin.registries.bitstream-formats'} }, ]) ] diff --git a/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats-routing.module.ts b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats-routing.module.ts index 67f6aa373e..2f08f8257c 100644 --- a/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats-routing.module.ts +++ b/src/app/+admin/admin-registries/bitstream-formats/bitstream-formats-routing.module.ts @@ -4,6 +4,7 @@ import { BitstreamFormatsResolver } from './bitstream-formats.resolver'; import { EditBitstreamFormatComponent } from './edit-bitstream-format/edit-bitstream-format.component'; import { BitstreamFormatsComponent } from './bitstream-formats.component'; import { AddBitstreamFormatComponent } from './add-bitstream-format/add-bitstream-format.component'; +import { I18nBreadcrumbResolver } from '../../../core/breadcrumbs/i18n-breadcrumb.resolver'; const BITSTREAMFORMAT_EDIT_PATH = ':id/edit'; const BITSTREAMFORMAT_ADD_PATH = 'add'; @@ -17,14 +18,18 @@ const BITSTREAMFORMAT_ADD_PATH = 'add'; }, { path: BITSTREAMFORMAT_ADD_PATH, + resolve: { breadcrumb: I18nBreadcrumbResolver }, component: AddBitstreamFormatComponent, + data: {breadcrumbKey: 'admin.registries.bitstream-formats.create'} }, { path: BITSTREAMFORMAT_EDIT_PATH, component: EditBitstreamFormatComponent, resolve: { - bitstreamFormat: BitstreamFormatsResolver - } + bitstreamFormat: BitstreamFormatsResolver, + breadcrumb: I18nBreadcrumbResolver + }, + data: {breadcrumbKey: 'admin.registries.bitstream-formats.edit'} }, ]) ], diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.html b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.html index a254f20428..42b7558397 100644 --- a/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.html +++ b/src/app/+admin/admin-registries/metadata-registry/metadata-registry.component.html @@ -11,7 +11,6 @@ = new BehaviorSubject(true); + constructor(private registryService: RegistryService, private notificationsService: NotificationsService, private router: Router, @@ -50,14 +57,17 @@ export class MetadataRegistryComponent { */ onPageChange(event) { this.config.currentPage = event; - this.updateSchemas(); + this.forceUpdateSchemas(); } /** * Update the list of schemas by fetching it from the rest api or cache */ private updateSchemas() { - this.metadataSchemas = this.registryService.getMetadataSchemas(this.config); + this.metadataSchemas = this.needsUpdate$.pipe( + filter((update) => update === true), + switchMap(() => this.registryService.getMetadataSchemas(toFindListOptions(this.config))) + ); } /** @@ -65,8 +75,7 @@ export class MetadataRegistryComponent { * a new REST call */ public forceUpdateSchemas() { - this.registryService.clearMetadataSchemaRequests().subscribe(); - this.updateSchemas(); + this.needsUpdate$.next(true); } /** @@ -125,6 +134,7 @@ export class MetadataRegistryComponent { * Delete all the selected metadata schemas */ deleteSchemas() { + this.registryService.clearMetadataSchemaRequests().subscribe(); this.registryService.getSelectedMetadataSchemas().pipe(take(1)).subscribe( (schemas) => { const tasks$ = []; diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts b/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts index db2294ab59..a840d68dcf 100644 --- a/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts +++ b/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.spec.ts @@ -21,7 +21,8 @@ describe('MetadataSchemaFormComponent', () => { const registryServiceStub = { getActiveMetadataSchema: () => observableOf(undefined), createOrUpdateMetadataSchema: (schema: MetadataSchema) => observableOf(schema), - cancelEditMetadataSchema: () => {} + cancelEditMetadataSchema: () => {}, + clearMetadataSchemaRequests: () => observableOf(undefined) }; const formBuilderServiceStub = { createFormGroup: () => { diff --git a/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.ts b/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.ts index 23e7309a00..79129d68a4 100644 --- a/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.ts +++ b/src/app/+admin/admin-registries/metadata-registry/metadata-schema-form/metadata-schema-form.component.ts @@ -128,6 +128,7 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy { * Emit the updated/created schema using the EventEmitter submitForm */ onSubmit() { + this.registryService.clearMetadataSchemaRequests().subscribe(); this.registryService.getActiveMetadataSchema().pipe(take(1)).subscribe( (schema) => { const values = { @@ -139,7 +140,7 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy { this.submitForm.emit(newSchema); }); } else { - this.registryService.createOrUpdateMetadataSchema(Object.assign(new MetadataSchema(), { + this.registryService.createOrUpdateMetadataSchema(Object.assign(new MetadataSchema(), schema, { id: schema.id, prefix: (values.prefix ? values.prefix : schema.prefix), namespace: (values.namespace ? values.namespace : schema.namespace) @@ -148,6 +149,7 @@ export class MetadataSchemaFormComponent implements OnInit, OnDestroy { }); } this.clearFields(); + this.registryService.cancelEditMetadataSchema(); } ); } diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts b/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts index 402f9c0c86..98128a6a61 100644 --- a/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts +++ b/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.spec.ts @@ -30,6 +30,7 @@ describe('MetadataFieldFormComponent', () => { createOrUpdateMetadataField: (field: MetadataField) => observableOf(field), cancelEditMetadataField: () => {}, cancelEditMetadataSchema: () => {}, + clearMetadataFieldRequests: () => observableOf(undefined) }; const formBuilderServiceStub = { createFormGroup: () => { diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.ts b/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.ts index 0811530343..42f6441791 100644 --- a/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.ts +++ b/src/app/+admin/admin-registries/metadata-schema/metadata-field-form/metadata-field-form.component.ts @@ -153,6 +153,7 @@ export class MetadataFieldFormComponent implements OnInit, OnDestroy { * Emit the updated/created field using the EventEmitter submitForm */ onSubmit() { + this.registryService.clearMetadataFieldRequests().subscribe(); this.registryService.getActiveMetadataField().pipe(take(1)).subscribe( (field) => { const values = { @@ -166,7 +167,7 @@ export class MetadataFieldFormComponent implements OnInit, OnDestroy { this.submitForm.emit(newField); }); } else { - this.registryService.createOrUpdateMetadataField(Object.assign(new MetadataField(), { + this.registryService.createOrUpdateMetadataField(Object.assign(new MetadataField(), field, { id: field.id, schema: this.metadataSchema, element: (values.element ? values.element : field.element), @@ -177,6 +178,7 @@ export class MetadataFieldFormComponent implements OnInit, OnDestroy { }); } this.clearFields(); + this.registryService.cancelEditMetadataField(); } ); } diff --git a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.html b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.html index 4a7a4cf34d..49ef748349 100644 --- a/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.html +++ b/src/app/+admin/admin-registries/metadata-schema/metadata-schema.component.html @@ -1,36 +1,37 @@
{ it('should not call deleteAndReturnResponse on the bitstreamService for the unmarked field', () => { expect(bitstreamService.deleteAndReturnResponse).not.toHaveBeenCalledWith(bitstream1.id); }); + }); - it('should send out a patch for the move operations', () => { + describe('when dropBitstream is called', () => { + beforeEach((done) => { + comp.dropBitstream(bundle, { + fromIndex: 0, + toIndex: 50, + // tslint:disable-next-line:no-empty + finish: () => { + done(); + } + }) + }); + + it('should send out a patch for the move operation', () => { expect(bundleService.patch).toHaveBeenCalled(); }); }); diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts index 009cc978b2..45b8e23108 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-bitstreams.component.ts @@ -1,6 +1,6 @@ -import { ChangeDetectorRef, Component, Inject, OnDestroy } from '@angular/core'; +import { ChangeDetectorRef, Component, NgZone, OnDestroy } from '@angular/core'; import { AbstractItemUpdateComponent } from '../abstract-item-update/abstract-item-update.component'; -import { filter, map, switchMap, take, tap } from 'rxjs/operators'; +import { filter, map, switchMap, take } from 'rxjs/operators'; import { Observable } from 'rxjs/internal/Observable'; import { Subscription } from 'rxjs/internal/Subscription'; import { ItemDataService } from '../../../core/data/item-data.service'; @@ -9,8 +9,8 @@ import { ActivatedRoute, Router } from '@angular/router'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { TranslateService } from '@ngx-translate/core'; import { BitstreamDataService } from '../../../core/data/bitstream-data.service'; -import { hasValue, isNotEmpty, isNotEmptyOperator } from '../../../shared/empty.util'; -import { zip as observableZip, combineLatest as observableCombineLatest, of as observableOf } from 'rxjs'; +import { hasValue, isNotEmpty } from '../../../shared/empty.util'; +import { zip as observableZip, of as observableOf } from 'rxjs'; import { ErrorResponse, RestResponse } from '../../../core/cache/response.models'; import { ObjectCacheService } from '../../../core/cache/object-cache.service'; import { RequestService } from '../../../core/data/request.service'; @@ -22,8 +22,6 @@ import { Bundle } from '../../../core/shared/bundle.model'; import { FieldUpdate, FieldUpdates } from '../../../core/data/object-updates/object-updates.reducer'; import { Bitstream } from '../../../core/shared/bitstream.model'; import { FieldChangeType } from '../../../core/data/object-updates/object-updates.actions'; -import { Operation } from 'fast-json-patch'; -import { MoveOperation } from 'fast-json-patch/lib/core'; import { BundleDataService } from '../../../core/data/bundle-data.service'; import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model'; import { ResponsiveColumnSizes } from '../../../shared/responsive-table-sizes/responsive-column-sizes'; @@ -90,7 +88,8 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme public objectCache: ObjectCacheService, public requestService: RequestService, public cdRef: ChangeDetectorRef, - public bundleService: BundleDataService + public bundleService: BundleDataService, + public zone: NgZone ) { super(itemService, objectUpdatesService, router, notificationsService, translateService, route); } @@ -143,7 +142,6 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme /** * Submit the current changes - * Bitstreams that were dragged around send out a patch request with move operations to the rest API * Bitstreams marked as deleted send out a delete request to the rest API * Display notifications and reset the current item/updates */ @@ -151,32 +149,6 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme this.submitting = true; const bundlesOnce$ = this.bundles$.pipe(take(1)); - // Fetch all move operations for each bundle - const moveOperations$ = bundlesOnce$.pipe( - switchMap((bundles: Bundle[]) => observableZip(...bundles.map((bundle: Bundle) => - this.objectUpdatesService.getMoveOperations(bundle.self).pipe( - take(1), - map((operations: MoveOperation[]) => [...operations.map((operation: MoveOperation) => Object.assign(operation, { - from: `/_links/bitstreams${operation.from}/href`, - path: `/_links/bitstreams${operation.path}/href` - }))]) - ) - ))) - ); - - // Send out an immediate patch request for each bundle - const patchResponses$ = observableCombineLatest(bundlesOnce$, moveOperations$).pipe( - switchMap(([bundles, moveOperationList]: [Bundle[], Operation[][]]) => - observableZip(...bundles.map((bundle: Bundle, index: number) => { - if (isNotEmpty(moveOperationList[index])) { - return this.bundleService.patch(bundle, moveOperationList[index]); - } else { - return observableOf(undefined); - } - })) - ) - ); - // Fetch all removed bitstreams from the object update service const removedBitstreams$ = bundlesOnce$.pipe( switchMap((bundles: Bundle[]) => observableZip( @@ -201,19 +173,42 @@ export class ItemBitstreamsComponent extends AbstractItemUpdateComponent impleme ); // Perform the setup actions from above in order and display notifications - patchResponses$.pipe( - switchMap((responses: RestResponse[]) => { - this.displayNotifications('item.edit.bitstreams.notifications.move', responses); - return removedResponses$ - }), - take(1) - ).subscribe((responses: RestResponse[]) => { + removedResponses$.pipe(take(1)).subscribe((responses: RestResponse[]) => { this.displayNotifications('item.edit.bitstreams.notifications.remove', responses); this.reset(); this.submitting = false; }); } + /** + * A bitstream was dropped in a new location. Send out a Move Patch request to the REST API, display notifications, + * refresh the bundle's cache (so the lists can properly reload) and call the event's callback function (which will + * navigate the user to the correct page) + * @param bundle The bundle to send patch requests to + * @param event The event containing the index the bitstream came from and was dropped to + */ + dropBitstream(bundle: Bundle, event: any) { + this.zone.runOutsideAngular(() => { + if (hasValue(event) && hasValue(event.fromIndex) && hasValue(event.toIndex) && hasValue(event.finish)) { + const moveOperation = Object.assign({ + op: 'move', + from: `/_links/bitstreams/${event.fromIndex}/href`, + path: `/_links/bitstreams/${event.toIndex}/href` + }); + this.bundleService.patch(bundle, [moveOperation]).pipe(take(1)).subscribe((response: RestResponse) => { + this.zone.run(() => { + this.displayNotifications('item.edit.bitstreams.notifications.move', [response]); + // Remove all cached requests from this bundle and call the event's callback when the requests are cleared + this.requestService.removeByHrefSubstring(bundle.self).pipe( + filter((isCached) => isCached), + take(1) + ).subscribe(() => event.finish()); + }); + }); + } + }); + } + /** * Display notifications * - Error notification for each failed response with their message diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html index 58273bb931..c28ef9b525 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.html @@ -17,5 +17,5 @@
- + diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.ts index 115e326241..72e2055bf7 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.ts +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/item-edit-bitstream-bundle.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnInit, ViewChild, ViewContainerRef } from '@angular/core'; +import { Component, EventEmitter, Input, OnInit, Output, ViewChild, ViewContainerRef } from '@angular/core'; import { Bundle } from '../../../../core/shared/bundle.model'; import { Item } from '../../../../core/shared/item.model'; import { ResponsiveColumnSizes } from '../../../../shared/responsive-table-sizes/responsive-column-sizes'; @@ -36,6 +36,13 @@ export class ItemEditBitstreamBundleComponent implements OnInit { */ @Input() columnSizes: ResponsiveTableSizes; + /** + * Send an event when the user drops an object on the pagination + * The event contains details about the index the object came from and is dropped to (across the entirety of the list, + * not just within a single page) + */ + @Output() dropObject: EventEmitter = new EventEmitter(); + /** * The bootstrap sizes used for the Bundle Name column * This column stretches over the first 3 columns and thus is a combination of their sizes processed in ngOnInit diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.html b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.html index 25941f472e..9197b89796 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.html +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.html @@ -7,24 +7,29 @@ [collectionSize]="(objectsRD$ | async)?.payload?.totalElements" [disableRouteParameterUpdate]="true" (pageChange)="switchPage($event)"> -
-
+
+ +
- -
- + +
+ +
+
-
+
-
+ + diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.spec.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.spec.ts index 03d1d00520..118f2b1619 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.spec.ts +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.spec.ts @@ -16,12 +16,15 @@ import { ResponsiveTableSizes } from '../../../../../shared/responsive-table-siz import { ResponsiveColumnSizes } from '../../../../../shared/responsive-table-sizes/responsive-column-sizes'; import { createSuccessfulRemoteDataObject$ } from '../../../../../shared/remote-data.utils'; import { createPaginatedList } from '../../../../../shared/testing/utils.test'; +import { RequestService } from '../../../../../core/data/request.service'; describe('PaginatedDragAndDropBitstreamListComponent', () => { let comp: PaginatedDragAndDropBitstreamListComponent; let fixture: ComponentFixture; let objectUpdatesService: ObjectUpdatesService; let bundleService: BundleDataService; + let objectValuesPipe: ObjectValuesPipe; + let requestService: RequestService; const columnSizes = new ResponsiveTableSizes([ new ResponsiveColumnSizes(2, 2, 3, 4, 4), @@ -97,15 +100,24 @@ describe('PaginatedDragAndDropBitstreamListComponent', () => { ); bundleService = jasmine.createSpyObj('bundleService', { - getBitstreams: createSuccessfulRemoteDataObject$(createPaginatedList([bitstream1, bitstream2])) + getBitstreams: createSuccessfulRemoteDataObject$(createPaginatedList([bitstream1, bitstream2])), + getBitstreamsEndpoint: observableOf('') + }); + + objectValuesPipe = new ObjectValuesPipe(); + + requestService = jasmine.createSpyObj('requestService', { + hasByHrefObservable: observableOf(true) }); TestBed.configureTestingModule({ imports: [TranslateModule.forRoot()], - declarations: [PaginatedDragAndDropBitstreamListComponent, VarDirective, ObjectValuesPipe], + declarations: [PaginatedDragAndDropBitstreamListComponent, VarDirective], providers: [ { provide: ObjectUpdatesService, useValue: objectUpdatesService }, - { provide: BundleDataService, useValue: bundleService } + { provide: BundleDataService, useValue: bundleService }, + { provide: ObjectValuesPipe, useValue: objectValuesPipe }, + { provide: RequestService, useValue: requestService } ], schemas: [ NO_ERRORS_SCHEMA ] diff --git a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.ts b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.ts index 5548da4029..a288e9993a 100644 --- a/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.ts +++ b/src/app/+item-page/edit-item-page/item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component.ts @@ -8,6 +8,8 @@ import { switchMap } from 'rxjs/operators'; import { PaginatedSearchOptions } from '../../../../../shared/search/paginated-search-options.model'; import { ResponsiveTableSizes } from '../../../../../shared/responsive-table-sizes/responsive-table-sizes'; import { followLink } from '../../../../../shared/utils/follow-link-config.model'; +import { ObjectValuesPipe } from '../../../../../shared/utils/object-values-pipe'; +import { RequestService } from '../../../../../core/data/request.service'; @Component({ selector: 'ds-paginated-drag-and-drop-bitstream-list', @@ -33,8 +35,10 @@ export class PaginatedDragAndDropBitstreamListComponent extends AbstractPaginate constructor(protected objectUpdatesService: ObjectUpdatesService, protected elRef: ElementRef, - protected bundleService: BundleDataService) { - super(objectUpdatesService, elRef); + protected objectValuesPipe: ObjectValuesPipe, + protected bundleService: BundleDataService, + protected requestService: RequestService) { + super(objectUpdatesService, elRef, objectValuesPipe); } ngOnInit() { @@ -46,11 +50,17 @@ export class PaginatedDragAndDropBitstreamListComponent extends AbstractPaginate */ initializeObjectsRD(): void { this.objectsRD$ = this.currentPage$.pipe( - switchMap((page: number) => this.bundleService.getBitstreams( - this.bundle.id, - new PaginatedSearchOptions({pagination: Object.assign({}, this.options, { currentPage: page })}), - followLink('format') - )) + switchMap((page: number) => { + const paginatedOptions = new PaginatedSearchOptions({pagination: Object.assign({}, this.options, { currentPage: page })}); + return this.bundleService.getBitstreamsEndpoint(this.bundle.id, paginatedOptions).pipe( + switchMap((href) => this.requestService.hasByHrefObservable(href)), + switchMap(() => this.bundleService.getBitstreams( + this.bundle.id, + paginatedOptions, + followLink('format') + )) + ); + }) ); } diff --git a/src/app/+item-page/full/field-components/file-section/full-file-section.component.html b/src/app/+item-page/full/field-components/file-section/full-file-section.component.html index b8ab9bdb41..7c1719eb82 100644 --- a/src/app/+item-page/full/field-components/file-section/full-file-section.component.html +++ b/src/app/+item-page/full/field-components/file-section/full-file-section.component.html @@ -21,9 +21,9 @@
diff --git a/src/app/+item-page/simple/field-components/file-section/file-section.component.html b/src/app/+item-page/simple/field-components/file-section/file-section.component.html index 6533322e03..17e4a795e7 100644 --- a/src/app/+item-page/simple/field-components/file-section/file-section.component.html +++ b/src/app/+item-page/simple/field-components/file-section/file-section.component.html @@ -1,11 +1,11 @@ diff --git a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html index 911ba26b31..4809f206ae 100644 --- a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html +++ b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.html @@ -7,9 +7,9 @@ diff --git a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts index ac9eea6c0c..16b50d18f0 100644 --- a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts +++ b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.spec.ts @@ -1,5 +1,5 @@ import { ChangeDetectorRef, Component, NO_ERRORS_SCHEMA } from '@angular/core'; -import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { async, ComponentFixture, inject, TestBed, tick, fakeAsync } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { Store } from '@ngrx/store'; @@ -21,6 +21,8 @@ import { NotificationsServiceStub } from '../../shared/testing/notifications-ser import { SharedModule } from '../../shared/shared.module'; import { getMockScrollToService } from '../../shared/mocks/scroll-to-service.mock'; import { UploaderService } from '../../shared/uploader/uploader.service'; +import { By } from '@angular/platform-browser'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; describe('MyDSpaceNewSubmissionComponent test', () => { @@ -54,6 +56,11 @@ describe('MyDSpaceNewSubmissionComponent test', () => { { provide: ScrollToService, useValue: getMockScrollToService() }, { provide: Store, useValue: store }, { provide: TranslateService, useValue: translateService }, + { + provide: NgbModal, useValue: { + open: () => {/*comment*/} + } + }, ChangeDetectorRef, MyDSpaceNewSubmissionComponent, UploaderService @@ -86,6 +93,25 @@ describe('MyDSpaceNewSubmissionComponent test', () => { })); }); + describe('', () => { + let fixture: ComponentFixture; + let comp: MyDSpaceNewSubmissionComponent; + + beforeEach(() => { + fixture = TestBed.createComponent(MyDSpaceNewSubmissionComponent); + comp = fixture.componentInstance; + }); + + it('should call app.openDialog', () => { + spyOn(comp, 'openDialog'); + const submissionButton = fixture.debugElement.query(By.css('button.btn-primary')); + submissionButton.triggerEventHandler('click', { + preventDefault: () => {/**/ + } + }); + expect(comp.openDialog).toHaveBeenCalled(); + }); + }); }); // declare a test component diff --git a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts index 81d66bb5f7..8d20a5736a 100644 --- a/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts +++ b/src/app/+my-dspace-page/my-dspace-new-submission/my-dspace-new-submission.component.ts @@ -15,6 +15,9 @@ import { HALEndpointService } from '../../core/shared/hal-endpoint.service'; import { NotificationType } from '../../shared/notifications/models/notification-type'; import { hasValue } from '../../shared/empty.util'; import { SearchResult } from '../../shared/search/search-result.model'; +import { Router } from '@angular/router'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { CreateItemParentSelectorComponent } from 'src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; /** * This component represents the whole mydspace page header @@ -55,7 +58,9 @@ export class MyDSpaceNewSubmissionComponent implements OnDestroy, OnInit { private halService: HALEndpointService, private notificationsService: NotificationsService, private store: Store, - private translate: TranslateService) { + private translate: TranslateService, + private router: Router, + private modalService: NgbModal) { } /** @@ -105,6 +110,14 @@ export class MyDSpaceNewSubmissionComponent implements OnDestroy, OnInit { this.notificationsService.error(null, this.translate.get('mydspace.upload.upload-failed')); } + /** + * Method called on clicking the button "New Submition", It opens a dialog for + * select a collection. + */ + openDialog() { + this.modalService.open(CreateItemParentSelectorComponent); + } + /** * Unsubscribe from the subscription */ diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 0ba0851e4e..3bd2f64961 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -45,6 +45,20 @@ export function getProfileModulePath() { return `/${PROFILE_MODULE_PATH}`; } +const REGISTER_PATH = 'register'; + +export function getRegisterPath() { + return `/${REGISTER_PATH}`; + +} + +const FORGOT_PASSWORD_PATH = 'forgot'; + +export function getForgotPasswordPath() { + return `/${FORGOT_PASSWORD_PATH}`; + +} + const WORKFLOW_ITEM_MODULE_PATH = 'workflowitems'; export function getWorkflowItemModulePath() { @@ -71,6 +85,8 @@ export function getDSOPath(dso: DSpaceObject): string { { path: 'community-list', loadChildren: './community-list-page/community-list-page.module#CommunityListPageModule' }, { path: 'id', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' }, { path: 'handle', loadChildren: './+lookup-by-id/lookup-by-id.module#LookupIdModule' }, + { path: REGISTER_PATH, loadChildren: './register-page/register-page.module#RegisterPageModule' }, + { path: FORGOT_PASSWORD_PATH, loadChildren: './forgot-password/forgot-password.module#ForgotPasswordModule' }, { path: COMMUNITY_MODULE_PATH, loadChildren: './+community-page/community-page.module#CommunityPageModule' }, { path: COLLECTION_MODULE_PATH, loadChildren: './+collection-page/collection-page.module#CollectionPageModule' }, { path: ITEM_MODULE_PATH, loadChildren: './+item-page/item-page.module#ItemPageModule' }, @@ -98,6 +114,7 @@ export function getDSOPath(dso: DSpaceObject): string { path: PROFILE_MODULE_PATH, loadChildren: './profile-page/profile-page.module#ProfilePageModule', canActivate: [AuthenticatedGuard] }, + { path: 'processes', loadChildren: './process-page/process-page.module#ProcessPageModule', canActivate: [AuthenticatedGuard] }, { path: '**', pathMatch: 'full', component: PageNotFoundComponent }, ], { diff --git a/src/app/community-list-page/community-list-datasource.ts b/src/app/community-list-page/community-list-datasource.ts index 3a9d9f2077..b77cbb5246 100644 --- a/src/app/community-list-page/community-list-datasource.ts +++ b/src/app/community-list-page/community-list-datasource.ts @@ -1,3 +1,5 @@ +import { NgZone } from '@angular/core'; +import { FindListOptions } from '../core/data/request.models'; import { CommunityListService, FlatNode } from './community-list-service'; import { CollectionViewer, DataSource } from '@angular/cdk/typings/collections'; import { BehaviorSubject, Observable, } from 'rxjs'; @@ -14,21 +16,23 @@ export class CommunityListDatasource implements DataSource { private communityList$ = new BehaviorSubject([]); public loading$ = new BehaviorSubject(false); - constructor(private communityListService: CommunityListService) { + constructor(private communityListService: CommunityListService, + private zone: NgZone) { } connect(collectionViewer: CollectionViewer): Observable { return this.communityList$.asObservable(); } - loadCommunities(expandedNodes: FlatNode[]) { + loadCommunities(findOptions: FindListOptions, expandedNodes: FlatNode[]) { this.loading$.next(true); - - this.communityListService.loadCommunities(expandedNodes).pipe( - take(1), - finalize(() => this.loading$.next(false)), - ).subscribe((flatNodes: FlatNode[]) => { - this.communityList$.next(flatNodes); + this.zone.runOutsideAngular(() => { + this.communityListService.loadCommunities(findOptions, expandedNodes).pipe( + take(1), + finalize(() => this.zone.run(() => this.loading$.next(false))), + ).subscribe((flatNodes: FlatNode[]) => { + this.zone.run(() => this.communityList$.next(flatNodes)); + }); }); } 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 6b7ab2bd77..accd0f23a5 100644 --- a/src/app/community-list-page/community-list-service.spec.ts +++ b/src/app/community-list-page/community-list-service.spec.ts @@ -1,21 +1,19 @@ -import { of as observableOf } from 'rxjs'; -import { TestBed, inject, async } from '@angular/core/testing'; +import { inject, TestBed } from '@angular/core/testing'; import { Store } from '@ngrx/store'; +import { of as observableOf } from 'rxjs'; +import { take } from 'rxjs/operators'; import { AppState } from '../app.reducer'; +import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; +import { PaginatedList } from '../core/data/paginated-list'; +import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; import { StoreMock } from '../shared/testing/store.mock'; import { CommunityListService, FlatNode, toFlatNode } from './community-list-service'; import { CollectionDataService } from '../core/data/collection-data.service'; -import { PaginatedList } from '../core/data/paginated-list'; -import { PageInfo } from '../core/shared/page-info.model'; import { CommunityDataService } from '../core/data/community-data.service'; -import { - createFailedRemoteDataObject$, - createSuccessfulRemoteDataObject$ -} from '../shared/remote-data.utils'; import { Community } from '../core/shared/community.model'; import { Collection } from '../core/shared/collection.model'; -import { take } from 'rxjs/operators'; import { FindListOptions } from '../core/data/request.models'; +import { PageInfo } from '../core/shared/page-info.model'; describe('CommunityListService', () => { let store: StoreMock; @@ -210,13 +208,18 @@ describe('CommunityListService', () => { let flatNodeList; describe('None expanded: should return list containing only flatnodes of the test top communities page 1 and 2', () => { let findTopSpy; - beforeEach(() => { + beforeEach((done) => { findTopSpy = spyOn(communityDataServiceStub, 'findTop').and.callThrough(); - service.getNextPageTopCommunities(); - const sub = service.loadCommunities(null) - .subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + service.loadCommunities({ + currentPage: 2, + sort: new SortOptions('dc.title', SortDirection.ASC) + }, null) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('flatnode list should contain just flatnodes of top community list page 1 and 2', () => { expect(findTopSpy).toHaveBeenCalled(); @@ -236,10 +239,16 @@ describe('CommunityListService', () => { describe('should transform all communities in a list of flatnodes with possible subcoms and collections as subflatnodes if they\'re expanded', () => { let flatNodeList; describe('None expanded: should return list containing only flatnodes of the test top communities', () => { - beforeEach(() => { - const sub = service.loadCommunities(null) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + beforeEach((done) => { + service.loadCommunities({ + currentPage: 1, + sort: new SortOptions('dc.title', SortDirection.ASC) + }, null) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('length of flatnode list should be as big as top community list', () => { expect(flatNodeList.length).toEqual(mockListOfTopCommunitiesPage1.length); @@ -256,7 +265,7 @@ describe('CommunityListService', () => { }); }); describe('All top expanded, all page 1: should return list containing flatnodes of the communities in the test list and all its possible page-limited children (subcommunities and collections)', () => { - beforeEach(() => { + beforeEach((done) => { const expandedNodes = []; mockListOfTopCommunitiesPage1.map((community: Community) => { const communityFlatNode = toFlatNode(community, observableOf(true), 0, true, null); @@ -264,9 +273,15 @@ describe('CommunityListService', () => { communityFlatNode.currentCommunityPage = 1; expandedNodes.push(communityFlatNode); }); - const sub = service.loadCommunities(expandedNodes) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + service.loadCommunities({ + currentPage: 1, + sort: new SortOptions('dc.title', SortDirection.ASC) + }, expandedNodes) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('length of flatnode list should be as big as top community list and size of its possible page-limited children', () => { expect(flatNodeList.length).toEqual(mockListOfTopCommunitiesPage1.length + mockSubcommunities1Page1.length + mockSubcommunities1Page1.length); @@ -281,14 +296,20 @@ describe('CommunityListService', () => { }); }); describe('Just first top comm expanded, all page 1: should return list containing flatnodes of the communities in the test list and all its possible page-limited children (subcommunities and collections)', () => { - beforeEach(() => { + beforeEach((done) => { const communityFlatNode = toFlatNode(mockListOfTopCommunitiesPage1[0], observableOf(true), 0, true, null); communityFlatNode.currentCollectionPage = 1; communityFlatNode.currentCommunityPage = 1; const expandedNodes = [communityFlatNode]; - const sub = service.loadCommunities(expandedNodes) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + service.loadCommunities({ + currentPage: 1, + sort: new SortOptions('dc.title', SortDirection.ASC) + }, expandedNodes) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('length of flatnode list should be as big as top community list and size of page-limited children of first top community', () => { expect(flatNodeList.length).toEqual(mockListOfTopCommunitiesPage1.length + mockSubcommunities1Page1.length); @@ -300,14 +321,20 @@ describe('CommunityListService', () => { }); }); describe('Just second top comm expanded, collections at page 2: should return list containing flatnodes of the communities in the test list and all its possible page-limited children (subcommunities and collections)', () => { - beforeEach(() => { + beforeEach((done) => { const communityFlatNode = toFlatNode(mockListOfTopCommunitiesPage1[1], observableOf(true), 0, true, null); communityFlatNode.currentCollectionPage = 2; communityFlatNode.currentCommunityPage = 1; const expandedNodes = [communityFlatNode]; - const sub = service.loadCommunities(expandedNodes) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + service.loadCommunities({ + currentPage: 1, + sort: new SortOptions('dc.title', SortDirection.ASC) + }, expandedNodes) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('length of flatnode list should be as big as top community list and size of page-limited children of second top community', () => { expect(flatNodeList.length).toEqual(mockListOfTopCommunitiesPage1.length + mockCollectionsPage1.length + mockCollectionsPage2.length); @@ -333,10 +360,13 @@ describe('CommunityListService', () => { }); let flatNodeList; describe('None expanded: should return list containing only flatnodes of the communities in the test list', () => { - beforeEach(() => { - const sub = service.transformListOfCommunities(new PaginatedList(new PageInfo(), listOfCommunities), 0, null, null) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + beforeEach((done) => { + service.transformListOfCommunities(new PaginatedList(new PageInfo(), listOfCommunities), 0, null, null) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('length of flatnode list should be as big as community test list', () => { expect(flatNodeList.length).toEqual(listOfCommunities.length); @@ -353,7 +383,7 @@ describe('CommunityListService', () => { }); }); describe('All top expanded, all page 1: should return list containing flatnodes of the communities in the test list and all its possible page-limited children (subcommunities and collections)', () => { - beforeEach(() => { + beforeEach((done) => { const expandedNodes = []; listOfCommunities.map((community: Community) => { const communityFlatNode = toFlatNode(community, observableOf(true), 0, true, null); @@ -361,9 +391,12 @@ describe('CommunityListService', () => { communityFlatNode.currentCommunityPage = 1; expandedNodes.push(communityFlatNode); }); - const sub = service.transformListOfCommunities(new PaginatedList(new PageInfo(), listOfCommunities), 0, null, expandedNodes) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + service.transformListOfCommunities(new PaginatedList(new PageInfo(), listOfCommunities), 0, null, expandedNodes) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('length of flatnode list should be as big as community test list and size of its possible children', () => { expect(flatNodeList.length).toEqual(listOfCommunities.length + mockSubcommunities1Page1.length + mockSubcommunities1Page1.length); @@ -397,10 +430,13 @@ describe('CommunityListService', () => { }); let flatNodeList; describe('should return list containing only flatnode corresponding to that community', () => { - beforeEach(() => { - const sub = service.transformCommunity(communityWithNoSubcomsOrColls, 0, null, null) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + beforeEach((done) => { + service.transformCommunity(communityWithNoSubcomsOrColls, 0, null, null) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('length of flatnode list should be 1', () => { expect(flatNodeList.length).toEqual(1); @@ -426,10 +462,14 @@ describe('CommunityListService', () => { }); let flatNodeList; describe('should return list containing only flatnode corresponding to that community', () => { - beforeAll(() => { - const sub = service.transformCommunity(communityWithSubcoms, 0, null, null) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + beforeAll((done) => { + service.transformCommunity(communityWithSubcoms, 0, null, null) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); + }); it('length of flatnode list should be 1', () => { expect(flatNodeList.length).toEqual(1); @@ -455,14 +495,17 @@ describe('CommunityListService', () => { } }); let flatNodeList; - beforeEach(() => { + beforeEach((done) => { const communityFlatNode = toFlatNode(communityWithSubcoms, observableOf(true), 0, true, null); communityFlatNode.currentCollectionPage = 1; communityFlatNode.currentCommunityPage = 1; const expandedNodes = [communityFlatNode]; - const sub = service.transformCommunity(communityWithSubcoms, 0, null, expandedNodes) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + service.transformCommunity(communityWithSubcoms, 0, null, expandedNodes) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('list of flatnodes is length is 1 + nrOfSubcoms & first flatnode is of expanded test community', () => { expect(flatNodeList.length).toEqual(1 + mockSubcommunities1Page1.length); @@ -485,7 +528,7 @@ describe('CommunityListService', () => { describe('should return list containing flatnodes of that community, its collections of the first two pages', () => { let communityWithCollections; let flatNodeList; - beforeEach(() => { + beforeEach((done) => { communityWithCollections = Object.assign(new Community(), { id: '9076bd16-e69a-48d6-9e41-0238cb40d863', uuid: '9076bd16-e69a-48d6-9e41-0238cb40d863', @@ -500,9 +543,12 @@ describe('CommunityListService', () => { communityFlatNode.currentCollectionPage = 2; communityFlatNode.currentCommunityPage = 1; const expandedNodes = [communityFlatNode]; - const sub = service.transformCommunity(communityWithCollections, 0, null, expandedNodes) - .pipe(take(1)).subscribe((value) => flatNodeList = value); - sub.unsubscribe(); + service.transformCommunity(communityWithCollections, 0, null, expandedNodes) + .pipe(take(1)) + .subscribe((value) => { + flatNodeList = value; + done(); + }); }); it('list of flatnodes is length is 1 + nrOfCollections & first flatnode is of expanded test community', () => { expect(flatNodeList.length).toEqual(1 + mockCollectionsPage1.length + mockCollectionsPage2.length); @@ -533,7 +579,7 @@ describe('CommunityListService', () => { describe('getIsExpandable', () => { describe('should return true', () => { - it('if community has subcommunities', () => { + it('if community has subcommunities', (done) => { const communityWithSubcoms = Object.assign(new Community(), { id: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', uuid: '7669c72a-3f2a-451f-a3b9-9210e7a4c02f', @@ -546,9 +592,10 @@ describe('CommunityListService', () => { }); service.getIsExpandable(communityWithSubcoms).pipe(take(1)).subscribe((result) => { expect(result).toEqual(true); + done(); }); }); - it('if community has collections', () => { + it('if community has collections', (done) => { const communityWithCollections = Object.assign(new Community(), { id: '9076bd16-e69a-48d6-9e41-0238cb40d863', uuid: '9076bd16-e69a-48d6-9e41-0238cb40d863', @@ -561,11 +608,12 @@ describe('CommunityListService', () => { }); service.getIsExpandable(communityWithCollections).pipe(take(1)).subscribe((result) => { expect(result).toEqual(true); + done(); }); }); }); describe('should return false', () => { - it('if community has neither subcommunities nor collections', () => { + it('if community has neither subcommunities nor collections', (done) => { const communityWithNoSubcomsOrColls = Object.assign(new Community(), { id: 'efbf25e1-2d8c-4c28-8f3e-2e04c215be24', uuid: 'efbf25e1-2d8c-4c28-8f3e-2e04c215be24', @@ -578,6 +626,7 @@ describe('CommunityListService', () => { }); service.getIsExpandable(communityWithNoSubcomsOrColls).pipe(take(1)).subscribe((result) => { expect(result).toEqual(false); + done(); }); }); }); diff --git a/src/app/community-list-page/community-list-service.ts b/src/app/community-list-page/community-list-service.ts index be04887e71..a5c3506e3d 100644 --- a/src/app/community-list-page/community-list-service.ts +++ b/src/app/community-list-page/community-list-service.ts @@ -4,11 +4,12 @@ import { combineLatest as observableCombineLatest } from 'rxjs/internal/observab import { Observable, of as observableOf } from 'rxjs'; import { AppState } from '../app.reducer'; import { CommunityDataService } from '../core/data/community-data.service'; -import { PaginationComponentOptions } from '../shared/pagination/pagination-component-options.model'; -import { SortDirection, SortOptions } from '../core/cache/models/sort-options.model'; -import { catchError, filter, map, switchMap, take } from 'rxjs/operators'; +import { FindListOptions } from '../core/data/request.models'; +import { map, flatMap } from 'rxjs/operators'; import { Community } from '../core/shared/community.model'; import { Collection } from '../core/shared/collection.model'; +import { getSucceededRemoteData } from '../core/shared/operators'; +import { PageInfo } from '../core/shared/page-info.model'; import { hasValue, isNotEmpty } from '../shared/empty.util'; import { RemoteData } from '../core/data/remote-data'; import { PaginatedList } from '../core/data/paginated-list'; @@ -46,8 +47,7 @@ export class ShowMoreFlatNode { // Helper method to combine an flatten an array of observables of flatNode arrays export const combineAndFlatten = (obsList: Array>): Observable => observableCombineLatest(...obsList).pipe( - map((matrix: FlatNode[][]) => - matrix.reduce((combinedList, currentList: FlatNode[]) => [...combinedList, ...currentList])) + map((matrix: any[][]) => [].concat(...matrix)) ); /** @@ -99,6 +99,8 @@ const communityListStateSelector = (state: AppState) => state.communityList; const expandedNodesSelector = createSelector(communityListStateSelector, (communityList: CommunityListState) => communityList.expandedNodes); const loadingNodeSelector = createSelector(communityListStateSelector, (communityList: CommunityListState) => communityList.loadingNode); +export const MAX_COMCOLS_PER_PAGE = 50; + /** * Service class for the community list, responsible for the creating of the flat list used by communityList dataSource * and connection to the store to retrieve and save the state of the community list @@ -107,26 +109,8 @@ const loadingNodeSelector = createSelector(communityListStateSelector, (communit @Injectable() export class CommunityListService { - // page-limited list of top-level communities - payloads$: Array>>; - - topCommunitiesConfig: PaginationComponentOptions; - topCommunitiesSortConfig: SortOptions; - - maxSubCommunitiesPerPage: number; - maxCollectionsPerPage: number; - constructor(private communityDataService: CommunityDataService, private collectionDataService: CollectionDataService, private store: Store) { - this.topCommunitiesConfig = new PaginationComponentOptions(); - this.topCommunitiesConfig.id = 'top-level-pagination'; - this.topCommunitiesConfig.pageSize = 10; - this.topCommunitiesConfig.currentPage = 1; - this.topCommunitiesSortConfig = new SortOptions('dc.title', SortDirection.ASC); - this.initTopCommunityList(); - - this.maxSubCommunitiesPerPage = 3; - this.maxCollectionsPerPage = 3; } saveCommunityListStateToStore(expandedNodes: FlatNode[], loadingNode: FlatNode): void { @@ -141,57 +125,46 @@ export class CommunityListService { return this.store.select(loadingNodeSelector); } - /** - * Increases the payload so it contains the next page of top level communities - */ - getNextPageTopCommunities(): void { - this.topCommunitiesConfig.currentPage = this.topCommunitiesConfig.currentPage + 1; - this.payloads$ = [...this.payloads$, this.communityDataService.findTop({ - currentPage: this.topCommunitiesConfig.currentPage, - elementsPerPage: this.topCommunitiesConfig.pageSize, - sort: { - field: this.topCommunitiesSortConfig.field, - direction: this.topCommunitiesSortConfig.direction - } - }).pipe( - take(1), - map((results) => results.payload), - )]; - } - /** * Gets all top communities, limited by page, and transforms this in a list of flatNodes. * @param expandedNodes List of expanded nodes; if a node is not expanded its subCommunities and collections need * not be added to the list */ - loadCommunities(expandedNodes: FlatNode[]): Observable { - const res = this.payloads$.map((payload) => { - return payload.pipe( - take(1), - switchMap((result: PaginatedList) => { - return this.transformListOfCommunities(result, 0, null, expandedNodes); - }), - catchError(() => observableOf([])), - ); - }); - return combineAndFlatten(res); + loadCommunities(findOptions: FindListOptions, expandedNodes: FlatNode[]): Observable { + const currentPage = findOptions.currentPage; + const topCommunities = []; + for (let i = 1; i <= currentPage; i++) { + const pagination: FindListOptions = Object.assign({}, findOptions, { currentPage: i }); + topCommunities.push(this.getTopCommunities(pagination)); + } + const topComs$ = observableCombineLatest(...topCommunities).pipe( + map((coms: Array>) => { + const newPages: Community[][] = coms.map((unit: PaginatedList) => unit.page); + const newPage: Community[] = [].concat(...newPages); + let newPageInfo = new PageInfo(); + if (coms && coms.length > 0) { + newPageInfo = Object.assign({}, coms[0].pageInfo, { currentPage }) + } + return new PaginatedList(newPageInfo, newPage); + }) + ); + return topComs$.pipe(flatMap((topComs: PaginatedList) => this.transformListOfCommunities(topComs, 0, null, expandedNodes))); }; /** * Puts the initial top level communities in a list to be called upon */ - private initTopCommunityList(): void { - this.payloads$ = [this.communityDataService.findTop({ - currentPage: this.topCommunitiesConfig.currentPage, - elementsPerPage: this.topCommunitiesConfig.pageSize, + private getTopCommunities(options: FindListOptions): Observable> { + return this.communityDataService.findTop({ + currentPage: options.currentPage, + elementsPerPage: MAX_COMCOLS_PER_PAGE, sort: { - field: this.topCommunitiesSortConfig.field, - direction: this.topCommunitiesSortConfig.direction + field: options.sort.field, + direction: options.sort.direction } }).pipe( - take(1), map((results) => results.payload), - )]; + ); } /** @@ -206,16 +179,15 @@ export class CommunityListService { parent: FlatNode, expandedNodes: FlatNode[]): Observable { if (isNotEmpty(listOfPaginatedCommunities.page)) { - let currentPage = this.topCommunitiesConfig.currentPage; + let currentPage = listOfPaginatedCommunities.currentPage; if (isNotEmpty(parent)) { currentPage = expandedNodes.find((node: FlatNode) => node.id === parent.id).currentCommunityPage; } - const isNotAllCommunities = (listOfPaginatedCommunities.totalElements > (listOfPaginatedCommunities.elementsPerPage * currentPage)); let obsList = listOfPaginatedCommunities.page .map((community: Community) => { return this.transformCommunity(community, level, parent, expandedNodes) }); - if (isNotAllCommunities && listOfPaginatedCommunities.currentPage > currentPage) { + if (currentPage < listOfPaginatedCommunities.totalPages && currentPage === listOfPaginatedCommunities.currentPage) { obsList = [...obsList, observableOf([showMoreFlatNode('community', level, parent)])]; } @@ -252,13 +224,12 @@ export class CommunityListService { let subcoms = []; for (let i = 1; i <= currentCommunityPage; i++) { const nextSetOfSubcommunitiesPage = this.communityDataService.findByParent(community.uuid, { - elementsPerPage: this.maxSubCommunitiesPerPage, + elementsPerPage: MAX_COMCOLS_PER_PAGE, currentPage: i }) .pipe( - filter((rd: RemoteData>) => rd.hasSucceeded), - take(1), - switchMap((rd: RemoteData>) => + getSucceededRemoteData(), + flatMap((rd: RemoteData>) => this.transformListOfCommunities(rd.payload, level + 1, communityFlatNode, expandedNodes)) ); @@ -271,16 +242,15 @@ export class CommunityListService { let collections = []; for (let i = 1; i <= currentCollectionPage; i++) { const nextSetOfCollectionsPage = this.collectionDataService.findByParent(community.uuid, { - elementsPerPage: this.maxCollectionsPerPage, + elementsPerPage: MAX_COMCOLS_PER_PAGE, currentPage: i }) .pipe( - filter((rd: RemoteData>) => rd.hasSucceeded), - take(1), + getSucceededRemoteData(), map((rd: RemoteData>) => { let nodes = rd.payload.page .map((collection: Collection) => toFlatNode(collection, observableOf(false), level + 1, false, communityFlatNode)); - if ((rd.payload.elementsPerPage * currentCollectionPage) < rd.payload.totalElements && rd.payload.currentPage > currentCollectionPage) { + if (currentCollectionPage < rd.payload.totalPages && currentCollectionPage === rd.payload.currentPage) { nodes = [...nodes, showMoreFlatNode('collection', level + 1, communityFlatNode)]; } return nodes; @@ -305,21 +275,18 @@ export class CommunityListService { let hasColls$: Observable; hasSubcoms$ = this.communityDataService.findByParent(community.uuid, { elementsPerPage: 1 }) .pipe( - filter((rd: RemoteData>) => rd.hasSucceeded), - take(1), + getSucceededRemoteData(), map((results) => results.payload.totalElements > 0), ); hasColls$ = this.collectionDataService.findByParent(community.uuid, { elementsPerPage: 1 }) .pipe( - filter((rd: RemoteData>) => rd.hasSucceeded), - take(1), + getSucceededRemoteData(), map((results) => results.payload.totalElements > 0), ); let hasChildren$: Observable; hasChildren$ = observableCombineLatest(hasSubcoms$, hasColls$).pipe( - take(1), map(([hasSubcoms, hasColls]: [boolean, boolean]) => { if (hasSubcoms || hasColls) { return true; diff --git a/src/app/community-list-page/community-list/community-list.component.spec.ts b/src/app/community-list-page/community-list/community-list.component.spec.ts index a91c5fa057..ef9e89ff1b 100644 --- a/src/app/community-list-page/community-list/community-list.component.spec.ts +++ b/src/app/community-list-page/community-list/community-list.component.spec.ts @@ -114,15 +114,9 @@ describe('CommunityListComponent', () => { beforeEach(async(() => { communityListServiceStub = { - topPageSize: 2, - topCurrentPage: 1, - collectionPageSize: 2, - subcommunityPageSize: 2, + pageSize: 2, expandedNodes: [], loadingNode: null, - getNextPageTopCommunities() { - this.topCurrentPage++; - }, getLoadingNodeFromStore() { return observableOf(this.loadingNode); }, @@ -133,12 +127,12 @@ describe('CommunityListComponent', () => { this.expandedNodes = expandedNodes; this.loadingNode = loadingNode; }, - loadCommunities(expandedNodes) { + loadCommunities(options, expandedNodes) { let flatnodes; let showMoreTopComNode = false; flatnodes = [...mockTopFlatnodesUnexpanded]; - const currentPage = this.topCurrentPage; - const elementsPerPage = this.topPageSize; + const currentPage = options.currentPage; + const elementsPerPage = this.pageSize; let endPageIndex = (currentPage * elementsPerPage); if (endPageIndex >= flatnodes.length) { endPageIndex = flatnodes.length; @@ -171,14 +165,14 @@ describe('CommunityListComponent', () => { collFlatnodes = [...collFlatnodes, toFlatNode(coll, observableOf(false), topNode.level + 1, false, topNode)]; }); if (isNotEmpty(subComFlatnodes)) { - const endSubComIndex = this.subcommunityPageSize * expandedParent.currentCommunityPage; + const endSubComIndex = this.pageSize * expandedParent.currentCommunityPage; flatnodes = [...flatnodes, ...subComFlatnodes.slice(0, endSubComIndex)]; if (subComFlatnodes.length > endSubComIndex) { flatnodes = [...flatnodes, showMoreFlatNode('community', topNode.level + 1, expandedParent)]; } } if (isNotEmpty(collFlatnodes)) { - const endColIndex = this.collectionPageSize * expandedParent.currentCollectionPage; + const endColIndex = this.pageSize * expandedParent.currentCollectionPage; flatnodes = [...flatnodes, ...collFlatnodes.slice(0, endColIndex)]; if (collFlatnodes.length > endColIndex) { flatnodes = [...flatnodes, showMoreFlatNode('collection', topNode.level + 1, expandedParent)]; diff --git a/src/app/community-list-page/community-list/community-list.component.ts b/src/app/community-list-page/community-list/community-list.component.ts index ddcd49cd1c..be96ff1a0a 100644 --- a/src/app/community-list-page/community-list/community-list.component.ts +++ b/src/app/community-list-page/community-list/community-list.component.ts @@ -1,5 +1,7 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component, NgZone, OnDestroy, OnInit } from '@angular/core'; import { take } from 'rxjs/operators'; +import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model'; +import { FindListOptions } from '../../core/data/request.models'; import { CommunityListService, FlatNode } from '../community-list-service'; import { CommunityListDatasource } from '../community-list-datasource'; import { FlatTreeControl } from '@angular/cdk/tree'; @@ -27,17 +29,24 @@ export class CommunityListComponent implements OnInit, OnDestroy { dataSource: CommunityListDatasource; - constructor(private communityListService: CommunityListService) { + paginationConfig: FindListOptions; + + constructor(private communityListService: CommunityListService, + private zone: NgZone) { + this.paginationConfig = new FindListOptions(); + this.paginationConfig.elementsPerPage = 2; + this.paginationConfig.currentPage = 1; + this.paginationConfig.sort = new SortOptions('dc.title', SortDirection.ASC); } ngOnInit() { - this.dataSource = new CommunityListDatasource(this.communityListService); + this.dataSource = new CommunityListDatasource(this.communityListService, this.zone); this.communityListService.getLoadingNodeFromStore().pipe(take(1)).subscribe((result) => { this.loadingNode = result; }); this.communityListService.getExpandedNodesFromStore().pipe(take(1)).subscribe((result) => { this.expandedNodes = [...result]; - this.dataSource.loadCommunities(this.expandedNodes); + this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes); }); } @@ -74,7 +83,7 @@ export class CommunityListComponent implements OnInit, OnDestroy { node.currentCommunityPage = 1; } } - this.dataSource.loadCommunities(this.expandedNodes); + this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes); } /** @@ -94,10 +103,10 @@ export class CommunityListComponent implements OnInit, OnDestroy { const parentNodeInExpandedNodes = this.expandedNodes.find((node2: FlatNode) => node.parent.id === node2.id); parentNodeInExpandedNodes.currentCommunityPage++; } - this.dataSource.loadCommunities(this.expandedNodes); + this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes); } else { - this.communityListService.getNextPageTopCommunities(); - this.dataSource.loadCommunities(this.expandedNodes); + this.paginationConfig.currentPage++; + this.dataSource.loadCommunities(this.paginationConfig, this.expandedNodes); } } diff --git a/src/app/core/auth/auth-request.service.ts b/src/app/core/auth/auth-request.service.ts index 465fb69dd2..93f55389f9 100644 --- a/src/app/core/auth/auth-request.service.ts +++ b/src/app/core/auth/auth-request.service.ts @@ -1,12 +1,18 @@ import { Observable, of as observableOf, throwError as observableThrowError } from 'rxjs'; -import { distinctUntilChanged, filter, map, mergeMap, tap } from 'rxjs/operators'; -import { Inject, Injectable } from '@angular/core'; +import { distinctUntilChanged, filter, map, mergeMap, switchMap, tap } from 'rxjs/operators'; +import { Injectable } from '@angular/core'; import { HALEndpointService } from '../shared/hal-endpoint.service'; import { RequestService } from '../data/request.service'; -import { GlobalConfig } from '../../../config/global-config.interface'; import { isNotEmpty } from '../../shared/empty.util'; -import { AuthGetRequest, AuthPostRequest, GetRequest, PostRequest, RestRequest } from '../data/request.models'; -import { AuthStatusResponse, ErrorResponse } from '../cache/response.models'; +import { + AuthGetRequest, + AuthPostRequest, + GetRequest, + PostRequest, + RestRequest, + TokenPostRequest +} from '../data/request.models'; +import { AuthStatusResponse, ErrorResponse, TokenResponse } 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'; @@ -15,6 +21,7 @@ import { HttpClient } from '@angular/common/http'; export class AuthRequestService { protected linkName = 'authn'; protected browseEndpoint = ''; + protected shortlivedtokensEndpoint = 'shortlivedtokens'; constructor(protected halService: HALEndpointService, protected requestService: RequestService, @@ -67,4 +74,19 @@ export class AuthRequestService { mergeMap((request: GetRequest) => this.fetchRequest(request)), distinctUntilChanged()); } + + /** + * Send a POST request to retrieve a short-lived token which provides download access of restricted files + */ + public getShortlivedToken(): Observable { + return this.halService.getEndpoint(`${this.linkName}/${this.shortlivedtokensEndpoint}`).pipe( + filter((href: string) => isNotEmpty(href)), + distinctUntilChanged(), + map((endpointURL: string) => new TokenPostRequest(this.requestService.generateRequestId(), endpointURL)), + tap((request: PostRequest) => this.requestService.configure(request)), + switchMap((request: PostRequest) => this.requestService.getByUUID(request.uuid)), + getResponseFromEntry(), + map((response: TokenResponse) => response.token) + ); + } } diff --git a/src/app/core/auth/auth.actions.ts b/src/app/core/auth/auth.actions.ts index 9237c30db9..be4bdf2a26 100644 --- a/src/app/core/auth/auth.actions.ts +++ b/src/app/core/auth/auth.actions.ts @@ -3,7 +3,6 @@ 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'; @@ -31,9 +30,6 @@ export const AuthActionTypes = { LOG_OUT: type('dspace/auth/LOG_OUT'), LOG_OUT_ERROR: type('dspace/auth/LOG_OUT_ERROR'), LOG_OUT_SUCCESS: type('dspace/auth/LOG_OUT_SUCCESS'), - REGISTRATION: type('dspace/auth/REGISTRATION'), - 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'), @@ -263,48 +259,6 @@ export class RetrieveTokenAction implements Action { public type: string = AuthActionTypes.RETRIEVE_TOKEN; } -/** - * Sign up. - * @class RegistrationAction - * @implements {Action} - */ -export class RegistrationAction implements Action { - public type: string = AuthActionTypes.REGISTRATION; - payload: EPerson; - - constructor(user: EPerson) { - this.payload = user; - } -} - -/** - * Sign up error. - * @class RegistrationErrorAction - * @implements {Action} - */ -export class RegistrationErrorAction implements Action { - public type: string = AuthActionTypes.REGISTRATION_ERROR; - payload: Error; - - constructor(payload: Error) { - this.payload = payload; - } -} - -/** - * Sign up success. - * @class RegistrationSuccessAction - * @implements {Action} - */ -export class RegistrationSuccessAction implements Action { - public type: string = AuthActionTypes.REGISTRATION_SUCCESS; - payload: EPerson; - - constructor(user: EPerson) { - this.payload = user; - } -} - /** * Add uthentication message. * @class AddAuthenticationMessageAction @@ -439,9 +393,6 @@ export type AuthActions | CheckAuthenticationTokenCookieAction | RedirectWhenAuthenticationIsRequiredAction | RedirectWhenTokenExpiredAction - | RegistrationAction - | RegistrationErrorAction - | RegistrationSuccessAction | AddAuthenticationMessageAction | RefreshTokenAction | RefreshTokenErrorAction diff --git a/src/app/core/auth/auth.effects.spec.ts b/src/app/core/auth/auth.effects.spec.ts index c08615ecc9..79fe385c6d 100644 --- a/src/app/core/auth/auth.effects.spec.ts +++ b/src/app/core/auth/auth.effects.spec.ts @@ -150,7 +150,8 @@ describe('AuthEffects', () => { describe('authenticatedSuccess$', () => { - it('should return a RETRIEVE_AUTHENTICATED_EPERSON action in response to a AUTHENTICATED_SUCCESS action', () => { + it('should return a RETRIEVE_AUTHENTICATED_EPERSON action in response to a AUTHENTICATED_SUCCESS action', (done) => { + spyOn((authEffects as any).authService, 'storeToken'); actions = hot('--a-', { a: { type: AuthActionTypes.AUTHENTICATED_SUCCESS, payload: { @@ -163,8 +164,14 @@ describe('AuthEffects', () => { const expected = cold('--b-', { b: new RetrieveAuthenticatedEpersonAction(EPersonMock._links.self.href) }); + authEffects.authenticatedSuccess$.subscribe(() => { + expect(authServiceStub.storeToken).toHaveBeenCalledWith(token); + }); + expect(authEffects.authenticatedSuccess$).toBeObservable(expected); + done(); }); + }); describe('checkToken$', () => { diff --git a/src/app/core/auth/auth.effects.ts b/src/app/core/auth/auth.effects.ts index c6d447961a..37ef3b79bc 100644 --- a/src/app/core/auth/auth.effects.ts +++ b/src/app/core/auth/auth.effects.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs'; -import { catchError, debounceTime, filter, map, switchMap, take, tap } from 'rxjs/operators'; +import { catchError, filter, map, switchMap, take, tap } from 'rxjs/operators'; // import @ngrx import { Actions, Effect, ofType } from '@ngrx/effects'; import { Action, select, Store } from '@ngrx/store'; @@ -30,9 +30,6 @@ import { RefreshTokenAction, RefreshTokenErrorAction, RefreshTokenSuccessAction, - RegistrationAction, - RegistrationErrorAction, - RegistrationSuccessAction, RetrieveAuthenticatedEpersonAction, RetrieveAuthenticatedEpersonErrorAction, RetrieveAuthenticatedEpersonSuccessAction, @@ -65,7 +62,6 @@ export class AuthEffects { @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)) ); @@ -82,6 +78,7 @@ export class AuthEffects { @Effect() public authenticatedSuccess$: Observable = this.actions$.pipe( ofType(AuthActionTypes.AUTHENTICATED_SUCCESS), + tap((action: AuthenticatedSuccessAction) => this.authService.storeToken(action.payload.authToken)), map((action: AuthenticatedSuccessAction) => new RetrieveAuthenticatedEpersonAction(action.payload.userHref)) ); @@ -136,18 +133,6 @@ export class AuthEffects { }) ); - @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))) - ); - }) - ); - @Effect() public retrieveToken$: Observable = this.actions$.pipe( ofType(AuthActionTypes.RETRIEVE_TOKEN), diff --git a/src/app/core/auth/auth.reducer.ts b/src/app/core/auth/auth.reducer.ts index 16990b35a8..34c8fe2b41 100644 --- a/src/app/core/auth/auth.reducer.ts +++ b/src/app/core/auth/auth.reducer.ts @@ -115,7 +115,6 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut }); case AuthActionTypes.AUTHENTICATE_ERROR: - case AuthActionTypes.REGISTRATION_ERROR: return Object.assign({}, state, { authenticated: false, authToken: undefined, @@ -157,18 +156,6 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut userId: undefined }); - case AuthActionTypes.REGISTRATION: - return Object.assign({}, state, { - authenticated: false, - authToken: undefined, - error: undefined, - loading: true, - info: undefined - }); - - case AuthActionTypes.REGISTRATION_SUCCESS: - return state; - case AuthActionTypes.REFRESH_TOKEN: return Object.assign({}, state, { refreshing: true, diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index 3b6fae4dd1..a15d604cc4 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -1,17 +1,14 @@ import { async, inject, TestBed } from '@angular/core/testing'; import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router } from '@angular/router'; - import { Store, StoreModule } from '@ngrx/store'; import { REQUEST } from '@nguniversal/express-engine/tokens'; import { of as observableOf } from 'rxjs'; - import { authReducer, AuthState } from './auth.reducer'; import { NativeWindowRef, NativeWindowService } from '../services/window.service'; import { AuthService, IMPERSONATING_COOKIE } 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'; @@ -49,6 +46,7 @@ describe('AuthService test', () => { let storage: CookieService; let token: AuthTokenInfo; let authenticatedState; + let unAuthenticatedState; let linkService; function init() { @@ -67,6 +65,13 @@ describe('AuthService test', () => { authToken: token, user: EPersonMock }; + unAuthenticatedState = { + authenticated: false, + loaded: true, + loading: false, + authToken: undefined, + user: undefined + }; authRequest = new AuthRequestServiceStub(); routeStub = new ActivatedRouteStub(); linkService = { @@ -214,6 +219,12 @@ describe('AuthService test', () => { }); }); + it('should return the shortlived token when user is logged in', () => { + authService.getShortlivedToken().subscribe((shortlivedToken: string) => { + expect(shortlivedToken).toEqual(authRequest.mockShortLivedToken); + }); + }); + it('should return token object when it is valid', () => { authService.hasValidAuthenticationToken().subscribe((tokenState: AuthTokenInfo) => { expect(tokenState).toBe(token); @@ -448,4 +459,44 @@ describe('AuthService test', () => { }); }); }); + + describe('when user is not logged in', () => { + beforeEach(async(() => { + init(); + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ authReducer }, { + runtimeChecks: { + strictStateImmutability: false, + strictActionImmutability: false + } + }) + ], + providers: [ + { provide: AuthRequestService, useValue: authRequest }, + { provide: REQUEST, useValue: {} }, + { provide: Router, useValue: routerStub }, + { provide: RouteService, useValue: routeServiceStub }, + { provide: RemoteDataBuildService, useValue: linkService }, + CookieService, + AuthService + ] + }).compileComponents(); + })); + + beforeEach(inject([CookieService, AuthRequestService, Store, Router, RouteService], (cookieService: CookieService, authReqService: AuthRequestService, store: Store, router: Router, routeService: RouteService) => { + store + .subscribe((state) => { + (state as any).core = Object.create({}); + (state as any).core.auth = unAuthenticatedState; + }); + authService = new AuthService({}, window, undefined, authReqService, mockEpersonDataService, router, routeService, cookieService, store); + })); + + it('should return null for the shortlived token', () => { + authService.getShortlivedToken().subscribe((shortlivedToken: string) => { + expect(shortlivedToken).toBeNull(); + }); + }); + }); }); diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 588d9e2675..fe9828bc73 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -270,18 +270,6 @@ export class AuthService { return observableOf(authMethods); } - /** - * Create a new user - * @returns {User} - */ - public create(user: EPerson): Observable { - // Normally you would do an HTTP request to POST the user - // details and then return the new user object - // but, let's just return the new user for this example. - // this._authenticated = true; - return observableOf(user); - } - /** * End session * @returns {Observable} @@ -546,4 +534,14 @@ export class AuthService { return this.getImpersonateID() === epersonId; } + /** + * Get a short-lived token for appending to download urls of restricted files + * Returns null if the user isn't authenticated + */ + getShortlivedToken(): Observable { + return this.isAuthenticated().pipe( + switchMap((authenticated) => authenticated ? this.authRequestService.getShortlivedToken() : observableOf(null)) + ); + } + } diff --git a/src/app/core/auth/token-response-parsing.service.spec.ts b/src/app/core/auth/token-response-parsing.service.spec.ts new file mode 100644 index 0000000000..35927708f6 --- /dev/null +++ b/src/app/core/auth/token-response-parsing.service.spec.ts @@ -0,0 +1,45 @@ +import { TokenResponseParsingService } from './token-response-parsing.service'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { TokenResponse } from '../cache/response.models'; + +describe('TokenResponseParsingService', () => { + let service: TokenResponseParsingService; + + beforeEach(() => { + service = new TokenResponseParsingService(); + }); + + describe('parse', () => { + it('should return a TokenResponse containing the token', () => { + const data = { + payload: { + token: 'valid-token' + }, + statusCode: 200, + statusText: 'OK' + } as DSpaceRESTV2Response; + const expected = new TokenResponse(data.payload.token, true, 200, 'OK'); + expect(service.parse(undefined, data)).toEqual(expected); + }); + + it('should return an empty TokenResponse when payload doesn\'t contain a token', () => { + const data = { + payload: {}, + statusCode: 200, + statusText: 'OK' + } as DSpaceRESTV2Response; + const expected = new TokenResponse(null, false, 200, 'OK'); + expect(service.parse(undefined, data)).toEqual(expected); + }); + + it('should return an error TokenResponse when the response failed', () => { + const data = { + payload: {}, + statusCode: 400, + statusText: 'BAD REQUEST' + } as DSpaceRESTV2Response; + const expected = new TokenResponse(null, false, 400, 'BAD REQUEST'); + expect(service.parse(undefined, data)).toEqual(expected); + }); + }); +}); diff --git a/src/app/core/auth/token-response-parsing.service.ts b/src/app/core/auth/token-response-parsing.service.ts new file mode 100644 index 0000000000..a1b1e23aa4 --- /dev/null +++ b/src/app/core/auth/token-response-parsing.service.ts @@ -0,0 +1,23 @@ +import { ResponseParsingService } from '../data/parsing.service'; +import { RestRequest } from '../data/request.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { RestResponse, TokenResponse } from '../cache/response.models'; +import { isNotEmpty } from '../../shared/empty.util'; +import { Injectable } from '@angular/core'; + +@Injectable() +/** + * A ResponseParsingService used to parse DSpaceRESTV2Response coming from the REST API to a token string + * wrapped in a TokenResponse + */ +export class TokenResponseParsingService implements ResponseParsingService { + + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + if (isNotEmpty(data.payload) && isNotEmpty(data.payload.token) && (data.statusCode === 200)) { + return new TokenResponse(data.payload.token, true, data.statusCode, data.statusText); + } else { + return new TokenResponse(null, false, data.statusCode, data.statusText) + } + } + +} diff --git a/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.spec.ts b/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.spec.ts index d34d6d8a9b..6b640a9db0 100644 --- a/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.spec.ts +++ b/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.spec.ts @@ -1,21 +1,38 @@ import { I18nBreadcrumbResolver } from './i18n-breadcrumb.resolver'; +import { URLCombiner } from '../url-combiner/url-combiner'; describe('I18nBreadcrumbResolver', () => { describe('resolve', () => { let resolver: I18nBreadcrumbResolver; let i18nBreadcrumbService: any; let i18nKey: string; - let path: string; + let route: any; + let parentSegment; + let segment; + let expectedPath; beforeEach(() => { i18nKey = 'example.key'; - path = 'rest.com/path/to/breadcrumb'; + parentSegment = 'path'; + segment = 'breadcrumb'; + route = { + data: { breadcrumbKey: i18nKey }, + routeConfig: { + path: segment + }, + parent: { + routeConfig: { + path: parentSegment + } + } as any + }; + expectedPath = new URLCombiner(parentSegment, segment).toString(); 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 }; + const resolvedConfig = resolver.resolve(route, {} as any); + const expectedConfig = { provider: i18nBreadcrumbService, key: i18nKey, url: expectedPath }; expect(resolvedConfig).toEqual(expectedConfig); }); diff --git a/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.ts b/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.ts index 1b8c5bcbd1..cce36f590a 100644 --- a/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.ts +++ b/src/app/core/breadcrumbs/i18n-breadcrumb.resolver.ts @@ -3,6 +3,7 @@ import { Injectable } from '@angular/core'; import { ActivatedRouteSnapshot, Resolve, RouterStateSnapshot } from '@angular/router'; import { I18nBreadcrumbsService } from './i18n-breadcrumbs.service'; import { hasNoValue } from '../../shared/empty.util'; +import { currentPathFromSnapshot } from '../../shared/utils/route.utils'; /** * The class that resolves a BreadcrumbConfig object with an i18n key string for a route @@ -25,7 +26,7 @@ export class I18nBreadcrumbResolver implements Resolve> 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(''); + const fullPath = currentPathFromSnapshot(route); return { provider: this.breadcrumbService, key: key, url: fullPath }; } } 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 88d1890de2..6c9f40888f 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -150,12 +150,7 @@ export class RemoteDataBuildService { filterSuccessfulResponses(), map((response: DSOSuccessResponse) => { if (hasValue((response as DSOSuccessResponse).pageInfo)) { - const resPageInfo = (response as DSOSuccessResponse).pageInfo; - if (isNotEmpty(resPageInfo) && resPageInfo.currentPage >= 0) { - return Object.assign({}, resPageInfo, { currentPage: resPageInfo.currentPage + 1 }); - } else { - return resPageInfo; - } + return (response as DSOSuccessResponse).pageInfo; } }) ); diff --git a/src/app/core/cache/response.models.ts b/src/app/core/cache/response.models.ts index 3f46ecf647..5f19185d1c 100644 --- a/src/app/core/cache/response.models.ts +++ b/src/app/core/cache/response.models.ts @@ -6,15 +6,13 @@ import { ConfigObject } from '../config/models/config.model'; import { FacetValue } from '../../shared/search/facet-value.model'; import { SearchFilterConfig } from '../../shared/search/search-filter-config.model'; import { IntegrationModel } from '../integration/models/integration.model'; -import { RegistryMetadataschemasResponse } from '../registry/registry-metadataschemas-response.model'; -import { RegistryMetadatafieldsResponse } from '../registry/registry-metadatafields-response.model'; -import { RegistryBitstreamformatsResponse } from '../registry/registry-bitstreamformats-response.model'; import { PaginatedList } from '../data/paginated-list'; import { SubmissionObject } from '../submission/models/submission-object.model'; import { DSpaceObject } from '../shared/dspace-object.model'; import { MetadataSchema } from '../metadata/metadata-schema.model'; import { MetadataField } from '../metadata/metadata-field.model'; import { ContentSource } from '../shared/content-source.model'; +import { Registration } from '../shared/registration.model'; /* tslint:disable:max-classes-per-file */ export class RestResponse { @@ -40,48 +38,6 @@ export class DSOSuccessResponse extends RestResponse { } } -/** - * A successful response containing a list of MetadataSchemas wrapped in a RegistryMetadataschemasResponse - */ -export class RegistryMetadataschemasSuccessResponse extends RestResponse { - constructor( - public metadataschemasResponse: RegistryMetadataschemasResponse, - public statusCode: number, - public statusText: string, - public pageInfo?: PageInfo - ) { - super(true, statusCode, statusText); - } -} - -/** - * A successful response containing a list of MetadataFields wrapped in a RegistryMetadatafieldsResponse - */ -export class RegistryMetadatafieldsSuccessResponse extends RestResponse { - constructor( - public metadatafieldsResponse: RegistryMetadatafieldsResponse, - public statusCode: number, - public statusText: string, - public pageInfo?: PageInfo - ) { - super(true, statusCode, statusText); - } -} - -/** - * A successful response containing a list of BitstreamFormats wrapped in a RegistryBitstreamformatsResponse - */ -export class RegistryBitstreamformatsSuccessResponse extends RestResponse { - constructor( - public bitstreamformatsResponse: RegistryBitstreamformatsResponse, - public statusCode: number, - public statusText: string, - public pageInfo?: PageInfo - ) { - super(true, statusCode, statusText); - } -} - /** * A successful response containing exactly one MetadataSchema */ @@ -211,6 +167,20 @@ export class AuthStatusResponse extends RestResponse { } } +/** + * A REST Response containing a token + */ +export class TokenResponse extends RestResponse { + constructor( + public token: string, + public isSuccessful: boolean, + public statusCode: number, + public statusText: string + ) { + super(isSuccessful, statusCode, statusText); + } +} + export class IntegrationSuccessResponse extends RestResponse { constructor( public dataDefinition: PaginatedList, @@ -302,4 +272,17 @@ export class ContentSourceSuccessResponse extends RestResponse { super(true, statusCode, statusText); } } + +/** + * A successful response containing a Registration + */ +export class RegistrationSuccessResponse extends RestResponse { + constructor( + public registration: Registration, + public statusCode: number, + public statusText: string, + ) { + super(true, statusCode, statusText); + } +} /* tslint:enable:max-classes-per-file */ diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index 715f7a5cc0..8c990ae0b1 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -1,7 +1,6 @@ import { CommonModule } from '@angular/common'; import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http'; import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core'; - import { DynamicFormLayoutService, DynamicFormService, DynamicFormValidationService } from '@ng-dynamic-forms/core'; import { EffectsModule } from '@ngrx/effects'; @@ -69,13 +68,8 @@ import { ItemDataService } from './data/item-data.service'; import { LicenseDataService } from './data/license-data.service'; import { LookupRelationService } from './data/lookup-relation.service'; 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 './resource-policy/resource-policy.service'; @@ -143,8 +137,17 @@ 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 { Script } from '../process-page/scripts/script.model'; +import { Process } from '../process-page/processes/process.model'; +import { ProcessDataService } from './data/processes/process-data.service'; +import { ScriptDataService } from './data/processes/script-data.service'; +import { ProcessFilesResponseParsingService } from './data/process-files-response-parsing.service'; import { WorkflowActionDataService } from './data/workflow-action-data.service'; import { WorkflowAction } from './tasks/models/workflow-action-object.model'; +import { Registration } from './shared/registration.model'; +import { MetadataSchemaDataService } from './data/metadata-schema-data.service'; +import { MetadataFieldDataService } from './data/metadata-field-data.service'; +import { TokenResponseParsingService } from './auth/token-response-parsing.service'; /** * When not in production, endpoint responses can be mocked for testing purposes @@ -201,9 +204,6 @@ const PROVIDERS = [ FacetValueResponseParsingService, FacetValueMapResponseParsingService, FacetConfigResponseParsingService, - RegistryMetadataschemasResponseParsingService, - RegistryMetadatafieldsResponseParsingService, - RegistryBitstreamformatsResponseParsingService, MappedCollectionsReponseParsingService, DebugResponseParsingService, SearchResponseParsingService, @@ -223,8 +223,6 @@ const PROVIDERS = [ JsonPatchOperationsBuilder, AuthorityService, IntegrationResponseParsingService, - MetadataschemaParsingService, - MetadatafieldParsingService, UploaderService, UUIDService, NotificationsService, @@ -264,6 +262,12 @@ const PROVIDERS = [ LicenseDataService, ItemTypeDataService, WorkflowActionDataService, + ProcessDataService, + ScriptDataService, + ProcessFilesResponseParsingService, + MetadataSchemaDataService, + MetadataFieldDataService, + TokenResponseParsingService, // register AuthInterceptor as HttpInterceptor { provide: HTTP_INTERCEPTORS, @@ -312,9 +316,12 @@ export const models = ItemType, ExternalSource, ExternalSourceEntry, + Script, + Process, Version, VersionHistory, - WorkflowAction + WorkflowAction, + Registration ]; @NgModule({ diff --git a/src/app/core/data/bundle-data.service.ts b/src/app/core/data/bundle-data.service.ts index 160ea0ff0d..de0e8a4337 100644 --- a/src/app/core/data/bundle-data.service.ts +++ b/src/app/core/data/bundle-data.service.ts @@ -88,10 +88,12 @@ export class BundleDataService extends DataService { /** * Get the bitstreams endpoint for a bundle * @param bundleId + * @param searchOptions */ - getBitstreamsEndpoint(bundleId: string): Observable { + getBitstreamsEndpoint(bundleId: string, searchOptions?: PaginatedSearchOptions): Observable { return this.getBrowseEndpoint().pipe( - switchMap((href: string) => this.halService.getEndpoint(this.bitstreamsEndpoint, `${href}/${bundleId}`)) + switchMap((href: string) => this.halService.getEndpoint(this.bitstreamsEndpoint, `${href}/${bundleId}`)), + map((href) => searchOptions ? searchOptions.toRestUrl(href) : href) ); } @@ -102,9 +104,8 @@ export class BundleDataService extends DataService { * @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) - ); + const hrefObs = this.getBitstreamsEndpoint(bundleId, searchOptions); + hrefObs.pipe( take(1) ).subscribe((href) => { diff --git a/src/app/core/data/collection-data.service.spec.ts b/src/app/core/data/collection-data.service.spec.ts index eb3dabf195..7087655a26 100644 --- a/src/app/core/data/collection-data.service.spec.ts +++ b/src/app/core/data/collection-data.service.spec.ts @@ -13,13 +13,19 @@ import { RequestEntry } from './request.reducer'; import { ErrorResponse, RestResponse } from '../cache/response.models'; import { ObjectCacheService } from '../cache/object-cache.service'; import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { Collection } from '../shared/collection.model'; +import { PageInfo } from '../shared/page-info.model'; +import { PaginatedList } from './paginated-list'; +import { createSuccessfulRemoteDataObject } from 'src/app/shared/remote-data.utils'; +import { hot, getTestScheduler, cold } from 'jasmine-marbles'; +import { TestScheduler } from 'rxjs/testing'; const url = 'fake-url'; const collectionId = 'fake-collection-id'; describe('CollectionDataService', () => { let service: CollectionDataService; - + let scheduler: TestScheduler; let requestService: RequestService; let translate: TranslateService; let notificationsService: any; @@ -27,6 +33,44 @@ describe('CollectionDataService', () => { let objectCache: ObjectCacheService; let halService: any; + const mockCollection1: Collection = Object.assign(new Collection(), { + id: 'test-collection-1-1', + name: 'test-collection-1', + _links: { + self: { + href: 'https://rest.api/collections/test-collection-1-1' + } + } + }); + + const mockCollection2: Collection = Object.assign(new Collection(), { + id: 'test-collection-2-2', + name: 'test-collection-2', + _links: { + self: { + href: 'https://rest.api/collections/test-collection-2-2' + } + } + }); + + const mockCollection3: Collection = Object.assign(new Collection(), { + id: 'test-collection-3-3', + name: 'test-collection-3', + _links: { + self: { + href: 'https://rest.api/collections/test-collection-3-3' + } + } + }); + + const queryString = 'test-string'; + const communityId = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a'; + + const pageInfo = new PageInfo(); + const array = [mockCollection1, mockCollection2, mockCollection3]; + const paginatedList = new PaginatedList(pageInfo, array); + const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList); + describe('when the requests are successful', () => { beforeEach(() => { createService(); @@ -74,6 +118,43 @@ describe('CollectionDataService', () => { }); }); + describe('when calling getAuthorizedCollection', () => { + beforeEach(() => { + scheduler = getTestScheduler(); + spyOn(service, 'getAuthorizedCollection').and.callThrough(); + spyOn(service, 'getAuthorizedCollectionByCommunity').and.callThrough(); + }); + + it('should proxy the call to getAuthorizedCollection', () => { + scheduler.schedule(() => service.getAuthorizedCollection(queryString)); + scheduler.flush(); + + expect(service.getAuthorizedCollection).toHaveBeenCalledWith(queryString); + }); + + it('should return a RemoteData> for the getAuthorizedCollection', () => { + const result = service.getAuthorizedCollection(queryString) + const expected = cold('a|', { + a: paginatedListRD + }); + expect(result).toBeObservable(expected); + }); + + it('should proxy the call to getAuthorizedCollectionByCommunity', () => { + scheduler.schedule(() => service.getAuthorizedCollectionByCommunity(communityId, queryString)); + scheduler.flush(); + + expect(service.getAuthorizedCollectionByCommunity).toHaveBeenCalledWith(communityId, queryString); + }); + + it('should return a RemoteData> for the getAuthorizedCollectionByCommunity', () => { + const result = service.getAuthorizedCollectionByCommunity(communityId, queryString) + const expected = cold('a|', { + a: paginatedListRD + }); + expect(result).toBeObservable(expected); + }); + }); }); describe('when the requests are unsuccessful', () => { @@ -117,7 +198,9 @@ describe('CollectionDataService', () => { function createService(requestEntry$?) { requestService = getMockRequestService(requestEntry$); rdbService = jasmine.createSpyObj('rdbService', { - buildList: jasmine.createSpy('buildList') + buildList: hot('a|', { + a: paginatedListRD + }) }); objectCache = jasmine.createSpyObj('objectCache', { remove: jasmine.createSpy('remove') diff --git a/src/app/core/data/collection-data.service.ts b/src/app/core/data/collection-data.service.ts index 0639a7d8ca..41f70dd31c 100644 --- a/src/app/core/data/collection-data.service.ts +++ b/src/app/core/data/collection-data.service.ts @@ -72,14 +72,18 @@ export class CollectionDataService extends ComColDataService { /** * Get all collections the user is authorized to submit to * + * @param query limit the returned collection to those with metadata values matching the query terms. * @param options The [[FindListOptions]] object * @return Observable>> * collection list */ - getAuthorizedCollection(options: FindListOptions = {}): Observable>> { - const searchHref = 'findAuthorized'; + getAuthorizedCollection(query: string, options: FindListOptions = {}, ...linksToFollow: Array>): Observable>> { + const searchHref = 'findSubmitAuthorized'; + options = Object.assign({}, options, { + searchParams: [new RequestParam('query', query)] + }); - return this.searchBy(searchHref, options).pipe( + return this.searchBy(searchHref, options, ...linksToFollow).pipe( filter((collections: RemoteData>) => !collections.isResponsePending)); } @@ -87,14 +91,18 @@ export class CollectionDataService extends ComColDataService { * Get all collections the user is authorized to submit to, by community * * @param communityId The community id + * @param query limit the returned collection to those with metadata values matching the query terms. * @param options The [[FindListOptions]] object * @return Observable>> * collection list */ - getAuthorizedCollectionByCommunity(communityId: string, options: FindListOptions = {}): Observable>> { - const searchHref = 'findAuthorizedByCommunity'; + getAuthorizedCollectionByCommunity(communityId: string, query: string, options: FindListOptions = {}): Observable>> { + const searchHref = 'findSubmitAuthorizedByCommunity'; options = Object.assign({}, options, { - searchParams: [new RequestParam('uuid', communityId)] + searchParams: [ + new RequestParam('uuid', communityId), + new RequestParam('query', query) + ] }); return this.searchBy(searchHref, options).pipe( @@ -108,7 +116,7 @@ export class CollectionDataService extends ComColDataService { * true if the user has at least one collection to submit to */ hasAuthorizedCollection(): Observable { - const searchHref = 'findAuthorized'; + const searchHref = 'findSubmitAuthorized'; const options = new FindListOptions(); options.elementsPerPage = 1; diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index ca59daa5af..7f77d72d3a 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -45,11 +45,12 @@ import { FindListOptions, FindListRequest, GetRequest, - PatchRequest + PatchRequest, PutRequest } from './request.models'; import { RequestEntry } from './request.reducer'; import { RequestService } from './request.service'; import { RestRequestMethod } from './rest-request-method'; +import { GenericConstructor } from '../shared/generic-constructor'; export abstract class DataService { protected abstract requestService: RequestService; @@ -343,7 +344,9 @@ export abstract class DataService { tap((href: string) => { this.requestService.removeByHrefSubstring(href); const request = new FindListRequest(this.requestService.generateRequestId(), href, options); - request.responseMsToLive = 10 * 1000; + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } this.requestService.configure(request); } @@ -381,6 +384,28 @@ export abstract class DataService { ); } + /** + * Send a PUT request for the specified object + * + * @param object The object to send a put request for. + */ + put(object: T): Observable> { + const requestId = this.requestService.generateRequestId(); + const serializedObject = new DSpaceSerializer(object.constructor as GenericConstructor<{}>).serialize(object); + const request = new PutRequest(requestId, object._links.self.href, serializedObject); + + if (hasValue(this.responseMsToLive)) { + request.responseMsToLive = this.responseMsToLive; + } + + this.requestService.configure(request); + + return this.requestService.getByUUID(requestId).pipe( + find((re: RequestEntry) => hasValue(re) && re.completed), + switchMap(() => this.findByHref(object._links.self.href)) + ); + } + /** * 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 diff --git a/src/app/core/data/eperson-registration.service.spec.ts b/src/app/core/data/eperson-registration.service.spec.ts new file mode 100644 index 0000000000..4c91ffd4f1 --- /dev/null +++ b/src/app/core/data/eperson-registration.service.spec.ts @@ -0,0 +1,89 @@ +import { RequestService } from './request.service'; +import { EpersonRegistrationService } from './eperson-registration.service'; +import { RegistrationSuccessResponse, RestResponse } from '../cache/response.models'; +import { RequestEntry } from './request.reducer'; +import { cold } from 'jasmine-marbles'; +import { PostRequest } from './request.models'; +import { Registration } from '../shared/registration.model'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; + +describe('EpersonRegistrationService', () => { + let service: EpersonRegistrationService; + let requestService: RequestService; + + let halService: any; + + const registration = new Registration(); + registration.email = 'test@mail.org'; + + const registrationWithUser = new Registration(); + registrationWithUser.email = 'test@mail.org'; + registrationWithUser.user = 'test-uuid'; + + beforeEach(() => { + halService = new HALEndpointServiceStub('rest-url'); + + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: 'request-id', + configure: {}, + getByUUID: cold('a', + {a: Object.assign(new RequestEntry(), {response: new RestResponse(true, 200, 'Success')})}) + }); + service = new EpersonRegistrationService( + requestService, + halService + ); + }); + + describe('getRegistrationEndpoint', () => { + it('should retrieve the registration endpoint', () => { + const expected = service.getRegistrationEndpoint(); + + expected.subscribe(((value) => { + expect(value).toEqual('rest-url/registrations'); + })); + }); + }); + + describe('getTokenSearchEndpoint', () => { + it('should return the token search endpoint for a specified token', () => { + const expected = service.getTokenSearchEndpoint('test-token'); + + expected.subscribe(((value) => { + expect(value).toEqual('rest-url/registrations/search/findByToken?token=test-token'); + })); + }); + }); + + describe('registerEmail', () => { + it('should send an email registration', () => { + + const expected = service.registerEmail('test@mail.org'); + + expect(requestService.configure).toHaveBeenCalledWith(new PostRequest('request-id', 'rest-url/registrations', registration)); + expect(expected).toBeObservable(cold('a', {a: new RestResponse(true, 200, 'Success')})); + }); + }); + + describe('searchByToken', () => { + beforeEach(() => { + (requestService.getByUUID as jasmine.Spy).and.returnValue( + cold('a', + {a: Object.assign(new RequestEntry(), {response: new RegistrationSuccessResponse(registrationWithUser, 200, 'Success')})}) + ); + }); + it('should return a registration corresponding to the provided token', () => { + const expected = service.searchByToken('test-token'); + + expect(expected).toBeObservable(cold('(a|)', { + a: Object.assign(new Registration(), { + email: registrationWithUser.email, + token: 'test-token', + user: registrationWithUser.user + }) + })); + + }); + }); + +}); diff --git a/src/app/core/data/eperson-registration.service.ts b/src/app/core/data/eperson-registration.service.ts new file mode 100644 index 0000000000..caa6150711 --- /dev/null +++ b/src/app/core/data/eperson-registration.service.ts @@ -0,0 +1,108 @@ +import { Injectable } from '@angular/core'; +import { RequestService } from './request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { GetRequest, PostRequest } from './request.models'; +import { Observable } from 'rxjs'; +import { filter, find, map, take } from 'rxjs/operators'; +import { hasValue, isNotEmpty } from '../../shared/empty.util'; +import { Registration } from '../shared/registration.model'; +import { filterSuccessfulResponses, getResponseFromEntry } from '../shared/operators'; +import { ResponseParsingService } from './parsing.service'; +import { GenericConstructor } from '../shared/generic-constructor'; +import { RegistrationResponseParsingService } from './registration-response-parsing.service'; +import { RegistrationSuccessResponse } from '../cache/response.models'; + +@Injectable( + { + providedIn: 'root', + } +) +/** + * Service that will register a new email address and request a token + */ +export class EpersonRegistrationService { + + protected linkPath = 'registrations'; + protected searchByTokenPath = '/search/findByToken?token='; + + constructor( + protected requestService: RequestService, + protected halService: HALEndpointService, + ) { + + } + + /** + * Retrieves the Registration endpoint + */ + getRegistrationEndpoint(): Observable { + return this.halService.getEndpoint(this.linkPath); + } + + /** + * Retrieves the endpoint to search by registration token + */ + getTokenSearchEndpoint(token: string): Observable { + return this.halService.getEndpoint(this.linkPath).pipe( + filter((href: string) => isNotEmpty(href)), + map((href: string) => `${href}${this.searchByTokenPath}${token}`)); + } + + /** + * Register a new email address + * @param email + */ + registerEmail(email: string) { + const registration = new Registration(); + registration.email = email; + + const requestId = this.requestService.generateRequestId(); + + const hrefObs = this.getRegistrationEndpoint(); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new PostRequest(requestId, href, registration); + this.requestService.configure(request); + }) + ).subscribe(); + + return this.requestService.getByUUID(requestId).pipe( + getResponseFromEntry() + ); + } + + /** + * Search a registration based on the provided token + * @param token + */ + searchByToken(token: string): Observable { + const requestId = this.requestService.generateRequestId(); + + const hrefObs = this.getTokenSearchEndpoint(token); + + hrefObs.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new GetRequest(requestId, href); + Object.assign(request, { + getResponseParser(): GenericConstructor { + return RegistrationResponseParsingService; + } + }); + this.requestService.configure(request); + }) + ).subscribe(); + + return this.requestService.getByUUID(requestId).pipe( + filterSuccessfulResponses(), + map((restResponse: RegistrationSuccessResponse) => { + return Object.assign(new Registration(), {email: restResponse.registration.email, token: token, user: restResponse.registration.user}); + }), + take(1), + ); + + } + +} diff --git a/src/app/core/data/metadata-field-data.service.spec.ts b/src/app/core/data/metadata-field-data.service.spec.ts new file mode 100644 index 0000000000..5c17b56845 --- /dev/null +++ b/src/app/core/data/metadata-field-data.service.spec.ts @@ -0,0 +1,114 @@ +import { RequestService } from './request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { of as observableOf } from 'rxjs/internal/observable/of'; +import { RestResponse } from '../cache/response.models'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; +import { CreateRequest, FindListOptions, PutRequest } from './request.models'; +import { MetadataFieldDataService } from './metadata-field-data.service'; +import { MetadataField } from '../metadata/metadata-field.model'; +import { MetadataSchema } from '../metadata/metadata-schema.model'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { RequestParam } from '../cache/models/request-param.model'; + +describe('MetadataFieldDataService', () => { + let metadataFieldService: MetadataFieldDataService; + let requestService: RequestService; + let halService: HALEndpointService; + let notificationsService: NotificationsService; + let schema: MetadataSchema; + let rdbService: RemoteDataBuildService; + + const endpoint = 'api/metadatafield/endpoint'; + + function init() { + schema = Object.assign(new MetadataSchema(), { + prefix: 'dc', + namespace: 'namespace', + _links: { + self: { href: 'selflink' } + } + }); + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: '34cfed7c-f597-49ef-9cbe-ea351f0023c2', + configure: {}, + getByUUID: observableOf({ response: new RestResponse(true, 200, 'OK') }), + removeByHrefSubstring: {} + }); + halService = Object.assign(new HALEndpointServiceStub(endpoint)); + notificationsService = jasmine.createSpyObj('notificationsService', { + error: {} + }); + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: createSuccessfulRemoteDataObject$(undefined) + }); + metadataFieldService = new MetadataFieldDataService(requestService, rdbService, undefined, halService, undefined, undefined, undefined, notificationsService); + } + + beforeEach(() => { + init(); + }); + + describe('findBySchema', () => { + beforeEach(() => { + spyOn(metadataFieldService, 'searchBy'); + }); + + it('should call searchBy with the correct arguments', () => { + metadataFieldService.findBySchema(schema); + const expectedOptions = Object.assign(new FindListOptions(), { + searchParams: [new RequestParam('schema', schema.prefix)] + }); + expect(metadataFieldService.searchBy).toHaveBeenCalledWith('bySchema', expectedOptions); + }); + }); + + describe('createOrUpdateMetadataField', () => { + let field: MetadataField; + + beforeEach(() => { + field = Object.assign(new MetadataField(), { + element: 'identifier', + qualifier: undefined, + schema: schema, + _links: { + self: { href: 'selflink' } + } + }); + }); + + describe('called with a new metadata field', () => { + it('should send a CreateRequest', (done) => { + metadataFieldService.createOrUpdateMetadataField(field).subscribe(() => { + expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(CreateRequest)); + done(); + }); + }); + }); + + describe('called with an existing metadata field', () => { + beforeEach(() => { + field = Object.assign(field, { + id: 'id-of-existing-field' + }); + }); + + it('should send a PutRequest', (done) => { + metadataFieldService.createOrUpdateMetadataField(field).subscribe(() => { + expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(PutRequest)); + done(); + }); + }); + }); + }); + + describe('clearRequests', () => { + it('should remove requests on the data service\'s endpoint', (done) => { + metadataFieldService.clearRequests().subscribe(() => { + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(`${endpoint}/${(metadataFieldService as any).linkPath}`); + done(); + }); + }); + }); +}); diff --git a/src/app/core/data/metadata-field-data.service.ts b/src/app/core/data/metadata-field-data.service.ts new file mode 100644 index 0000000000..f50be20f13 --- /dev/null +++ b/src/app/core/data/metadata-field-data.service.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@angular/core'; +import { dataService } from '../cache/builders/build-decorators'; +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 { DefaultChangeAnalyzer } from './default-change-analyzer.service'; +import { HttpClient } from '@angular/common/http'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { METADATA_FIELD } from '../metadata/metadata-field.resource-type'; +import { MetadataField } from '../metadata/metadata-field.model'; +import { MetadataSchema } from '../metadata/metadata-schema.model'; +import { FindListOptions, FindListRequest } from './request.models'; +import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model'; +import { Observable } from 'rxjs/internal/Observable'; +import { hasValue } from '../../shared/empty.util'; +import { find, skipWhile, switchMap, tap } from 'rxjs/operators'; +import { RemoteData } from './remote-data'; +import { RequestParam } from '../cache/models/request-param.model'; +import { PaginatedList } from './paginated-list'; + +/** + * A service responsible for fetching/sending data from/to the REST API on the metadatafields endpoint + */ +@Injectable() +@dataService(METADATA_FIELD) +export class MetadataFieldDataService extends DataService { + protected linkPath = 'metadatafields'; + protected searchBySchemaLinkPath = 'bySchema'; + + constructor( + protected requestService: RequestService, + protected rdbService: RemoteDataBuildService, + protected store: Store, + protected halService: HALEndpointService, + protected objectCache: ObjectCacheService, + protected comparator: DefaultChangeAnalyzer, + protected http: HttpClient, + protected notificationsService: NotificationsService) { + super(); + } + + /** + * Find metadata fields belonging to a metadata schema + * @param schema The metadata schema to list fields for + * @param options The options info used to retrieve the fields + * @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved + */ + findBySchema(schema: MetadataSchema, options: FindListOptions = {}, ...linksToFollow: Array>) { + const optionsWithSchema = Object.assign(new FindListOptions(), options, { + searchParams: [new RequestParam('schema', schema.prefix)] + }); + return this.searchBy(this.searchBySchemaLinkPath, optionsWithSchema, ...linksToFollow); + } + + /** + * Create or Update a MetadataField + * If the MetadataField contains an id, it is assumed the field already exists and is updated instead + * Since creating or updating is nearly identical, the only real difference is the request (and slight difference in endpoint): + * - On creation, a CreateRequest is used + * - On update, a PutRequest is used + * @param field The MetadataField to create or update + */ + createOrUpdateMetadataField(field: MetadataField): Observable> { + const isUpdate = hasValue(field.id); + + if (isUpdate) { + return this.put(field); + } else { + return this.create(field, new RequestParam('schemaId', field.schema.id)); + } + } + + /** + * Clear all metadata field requests + * Used for refreshing lists after adding/updating/removing a metadata field from a metadata schema + */ + clearRequests(): Observable { + return this.getBrowseEndpoint().pipe( + tap((href: string) => { + this.requestService.removeByHrefSubstring(href); + }) + ); + } + +} diff --git a/src/app/core/data/metadata-schema-data.service.spec.ts b/src/app/core/data/metadata-schema-data.service.spec.ts new file mode 100644 index 0000000000..bf73deecb7 --- /dev/null +++ b/src/app/core/data/metadata-schema-data.service.spec.ts @@ -0,0 +1,89 @@ +import { RequestService } from './request.service'; +import { HALEndpointService } from '../shared/hal-endpoint.service'; +import { NotificationsService } from '../../shared/notifications/notifications.service'; +import { MetadataSchemaDataService } from './metadata-schema-data.service'; +import { of as observableOf } from 'rxjs'; +import { RestResponse } from '../cache/response.models'; +import { HALEndpointServiceStub } from '../../shared/testing/hal-endpoint-service.stub'; +import { MetadataSchema } from '../metadata/metadata-schema.model'; +import { CreateRequest, PutRequest } from './request.models'; +import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service'; +import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils'; + +describe('MetadataSchemaDataService', () => { + let metadataSchemaService: MetadataSchemaDataService; + let requestService: RequestService; + let halService: HALEndpointService; + let notificationsService: NotificationsService; + let rdbService: RemoteDataBuildService; + + const endpoint = 'api/metadataschema/endpoint'; + + function init() { + requestService = jasmine.createSpyObj('requestService', { + generateRequestId: '34cfed7c-f597-49ef-9cbe-ea351f0023c2', + configure: {}, + getByUUID: observableOf({ response: new RestResponse(true, 200, 'OK') }), + removeByHrefSubstring: {} + }); + halService = Object.assign(new HALEndpointServiceStub(endpoint)); + notificationsService = jasmine.createSpyObj('notificationsService', { + error: {} + }); + rdbService = jasmine.createSpyObj('rdbService', { + buildSingle: createSuccessfulRemoteDataObject$(undefined) + }); + metadataSchemaService = new MetadataSchemaDataService(requestService, rdbService, undefined, halService, undefined, undefined, undefined, notificationsService); + } + + beforeEach(() => { + init(); + }); + + describe('createOrUpdateMetadataSchema', () => { + let schema: MetadataSchema; + + beforeEach(() => { + schema = Object.assign(new MetadataSchema(), { + prefix: 'dc', + namespace: 'namespace', + _links: { + self: { href: 'selflink' } + } + }); + }); + + describe('called with a new metadata schema', () => { + it('should send a CreateRequest', (done) => { + metadataSchemaService.createOrUpdateMetadataSchema(schema).subscribe(() => { + expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(CreateRequest)); + done(); + }); + }); + }); + + describe('called with an existing metadata schema', () => { + beforeEach(() => { + schema = Object.assign(schema, { + id: 'id-of-existing-schema' + }); + }); + + it('should send a PutRequest', (done) => { + metadataSchemaService.createOrUpdateMetadataSchema(schema).subscribe(() => { + expect(requestService.configure).toHaveBeenCalledWith(jasmine.any(PutRequest)); + done(); + }); + }); + }); + }); + + describe('clearRequests', () => { + it('should remove requests on the data service\'s endpoint', (done) => { + metadataSchemaService.clearRequests().subscribe(() => { + expect(requestService.removeByHrefSubstring).toHaveBeenCalledWith(`${endpoint}/${(metadataSchemaService as any).linkPath}`); + done(); + }); + }); + }); +}); diff --git a/src/app/core/data/metadata-schema-data.service.ts b/src/app/core/data/metadata-schema-data.service.ts index 915f588379..99a3f98b8e 100644 --- a/src/app/core/data/metadata-schema-data.service.ts +++ b/src/app/core/data/metadata-schema-data.service.ts @@ -9,37 +9,21 @@ 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 { DefaultChangeAnalyzer } from './default-change-analyzer.service'; import { RequestService } from './request.service'; - -/* tslint:disable:max-classes-per-file */ -class DataServiceImpl extends DataService { - protected linkPath = 'metadataschemas'; - - constructor( - protected requestService: RequestService, - protected rdbService: RemoteDataBuildService, - protected store: Store, - protected objectCache: ObjectCacheService, - protected halService: HALEndpointService, - protected notificationsService: NotificationsService, - protected http: HttpClient, - protected comparator: ChangeAnalyzer) { - super(); - } - -} +import { Observable } from 'rxjs/internal/Observable'; +import { hasValue } from '../../shared/empty.util'; +import { tap } from 'rxjs/operators'; +import { RemoteData } from './remote-data'; /** * 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; +export class MetadataSchemaDataService extends DataService { + protected linkPath = 'metadataschemas'; constructor( protected requestService: RequestService, @@ -50,6 +34,35 @@ export class MetadataSchemaDataService { protected comparator: DefaultChangeAnalyzer, protected http: HttpClient, protected notificationsService: NotificationsService) { - this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator); + super(); } + + /** + * Create or Update a MetadataSchema + * If the MetadataSchema contains an id, it is assumed the schema already exists and is updated instead + * Since creating or updating is nearly identical, the only real difference is the request (and slight difference in endpoint): + * - On creation, a CreateRequest is used + * - On update, a PutRequest is used + * @param schema The MetadataSchema to create or update + */ + createOrUpdateMetadataSchema(schema: MetadataSchema): Observable> { + const isUpdate = hasValue(schema.id); + + if (isUpdate) { + return this.put(schema); + } else { + return this.create(schema); + } + } + + /** + * Clear all metadata schema requests + * Used for refreshing lists after adding/updating/removing a metadata schema in the registry + */ + clearRequests(): Observable { + return this.getBrowseEndpoint().pipe( + tap((href: string) => this.requestService.removeByHrefSubstring(href)) + ); + } + } diff --git a/src/app/core/data/metadatafield-parsing.service.ts b/src/app/core/data/metadatafield-parsing.service.ts deleted file mode 100644 index 08f7892ac7..0000000000 --- a/src/app/core/data/metadatafield-parsing.service.ts +++ /dev/null @@ -1,22 +0,0 @@ -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 - */ -@Injectable() -export class MetadatafieldParsingService implements ResponseParsingService { - - parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - const payload = data.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 deleted file mode 100644 index f4b90e5dcd..0000000000 --- a/src/app/core/data/metadataschema-parsing.service.ts +++ /dev/null @@ -1,19 +0,0 @@ -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 { - - parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { - const payload = data.payload; - - const deserialized = new DSpaceSerializer(MetadataSchema).deserialize(payload); - return new MetadataschemaSuccessResponse(deserialized, data.statusCode, data.statusText); - } - -} 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 94918157ee..f26be768b1 100644 --- a/src/app/core/data/object-updates/object-updates.actions.ts +++ b/src/app/core/data/object-updates/object-updates.actions.ts @@ -8,7 +8,6 @@ import {INotification} from '../../../shared/notifications/models/notification.m */ 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'), @@ -17,8 +16,7 @@ export const ObjectUpdatesActionTypes = { 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'), + REMOVE_FIELD: type('dspace/core/cache/object-updates/REMOVE_FIELD') }; /* tslint:disable:max-classes-per-file */ @@ -29,8 +27,7 @@ export const ObjectUpdatesActionTypes = { export enum FieldChangeType { UPDATE = 0, ADD = 1, - REMOVE = 2, - MOVE = 3 + REMOVE = 2 } /** @@ -41,10 +38,7 @@ export class InitializeFieldsAction implements Action { payload: { url: string, fields: Identifiable[], - lastModified: Date, - order: string[], - pageSize: number, - page: number + lastModified: Date }; /** @@ -61,42 +55,9 @@ export class InitializeFieldsAction implements Action { constructor( url: string, fields: Identifiable[], - lastModified: Date, - order: string[] = [], - pageSize: number = 9999, - page: number = 0 + lastModified: Date ) { - 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 }; + this.payload = { url, fields, lastModified }; } } @@ -320,43 +281,6 @@ 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 */ /** @@ -369,8 +293,6 @@ export type ObjectUpdatesAction | ReinstateObjectUpdatesAction | RemoveObjectUpdatesAction | RemoveFieldUpdateAction - | MoveFieldUpdateAction - | AddPageToCustomOrderAction | RemoveAllObjectUpdatesAction | SelectVirtualMetadataAction | SetEditableFieldUpdateAction 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 bdf202049e..cb7f44039c 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,9 +1,9 @@ import * as deepFreeze from 'deep-freeze'; import { - AddFieldUpdateAction, AddPageToCustomOrderAction, + AddFieldUpdateAction, DiscardObjectUpdatesAction, FieldChangeType, - InitializeFieldsAction, MoveFieldUpdateAction, + InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveAllObjectUpdatesAction, RemoveFieldUpdateAction, RemoveObjectUpdatesAction, SelectVirtualMetadataAction, SetEditableFieldUpdateAction, SetValidFieldUpdateAction @@ -85,16 +85,6 @@ describe('objectUpdatesReducer', () => { 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 - } } }; @@ -121,16 +111,6 @@ describe('objectUpdatesReducer', () => { 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: { @@ -165,16 +145,6 @@ describe('objectUpdatesReducer', () => { 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 - } } }; @@ -243,7 +213,7 @@ describe('objectUpdatesReducer', () => { }); it('should initialize all fields when the INITIALIZE action is dispatched, based on the payload', () => { - const action = new InitializeFieldsAction(url, [identifiable1, identifiable3], modDate, [identifiable1.uuid, identifiable3.uuid], 10, 0); + const action = new InitializeFieldsAction(url, [identifiable1, identifiable3], modDate); const expectedState = { [url]: { @@ -261,17 +231,7 @@ describe('objectUpdatesReducer', () => { }, fieldUpdates: {}, virtualMetadataSources: {}, - lastModified: modDate, - customOrder: { - initialOrderPages: [ - { order: [identifiable1.uuid, identifiable3.uuid] } - ], - newOrderPages: [ - { order: [identifiable1.uuid, identifiable3.uuid] } - ], - pageSize: 10, - changed: false - } + lastModified: modDate } }; const newState = objectUpdatesReducer(testState, action); @@ -337,30 +297,4 @@ describe('objectUpdatesReducer', () => { 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 759a9f5c87..b1626a5ff5 100644 --- a/src/app/core/data/object-updates/object-updates.reducer.ts +++ b/src/app/core/data/object-updates/object-updates.reducer.ts @@ -1,8 +1,8 @@ import { - AddFieldUpdateAction, AddPageToCustomOrderAction, + AddFieldUpdateAction, DiscardObjectUpdatesAction, FieldChangeType, - InitializeFieldsAction, MoveFieldUpdateAction, + InitializeFieldsAction, ObjectUpdatesAction, ObjectUpdatesActionTypes, ReinstateObjectUpdatesAction, @@ -12,9 +12,7 @@ import { SetValidFieldUpdateAction, SelectVirtualMetadataAction, } from './object-updates.actions'; -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 { hasNoValue, hasValue } from '../../../shared/empty.util'; import {Relationship} from '../../shared/item-relationships/relationship.model'; /** @@ -83,20 +81,6 @@ export interface DeleteRelationship extends Relationship { 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 */ @@ -105,7 +89,6 @@ export interface ObjectUpdatesEntry { fieldUpdates: FieldUpdates; virtualMetadataSources: VirtualMetadataSources; lastModified: Date; - customOrder: CustomOrder } /** @@ -138,9 +121,6 @@ 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); } @@ -168,9 +148,6 @@ 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; } @@ -186,50 +163,18 @@ 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: {} }, { virtualMetadataSources: {} }, - { lastModified: lastModifiedServer }, - { customOrder: { - initialOrderPages: initialOrderPages, - newOrderPages: initialOrderPages, - pageSize: pageSize, - changed: false } - } + { lastModified: lastModifiedServer } ); 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 @@ -338,19 +283,9 @@ function discardObjectUpdatesFor(url: string, state: any) { } }); - 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, - customOrder: newCustomOrder + fieldStates: newFieldStates }); return Object.assign({}, state, { [url]: discardedPageState }, { [url + OBJECT_UPDATES_TRASH_PATH]: pageState }); } @@ -504,121 +439,3 @@ 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 780a402a84..04018b8de2 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,7 +2,6 @@ import { Store } from '@ngrx/store'; import { CoreState } from '../../core.reducers'; import { ObjectUpdatesService } from './object-updates.service'; import { - AddPageToCustomOrderAction, DiscardObjectUpdatesAction, FieldChangeType, InitializeFieldsAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, SelectVirtualMetadataAction, @@ -13,8 +12,6 @@ import { Notification } from '../../../shared/notifications/models/notification. 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; @@ -47,7 +44,7 @@ describe('ObjectUpdatesService', () => { }; store = new Store(undefined, undefined, undefined); spyOn(store, 'dispatch'); - service = new ObjectUpdatesService(store, new ArrayMoveChangeAnalyzer()); + service = new ObjectUpdatesService(store); spyOn(service as any, 'getObjectEntry').and.returnValue(observableOf(objectEntry)); spyOn(service as any, 'getFieldState').and.callFake((uuid) => { @@ -63,25 +60,6 @@ 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); @@ -116,49 +94,6 @@ describe('ObjectUpdatesService', () => { }); }); - 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); @@ -274,11 +209,7 @@ describe('ObjectUpdatesService', () => { }); describe('when updates are emtpy', () => { beforeEach(() => { - (service as any).getObjectEntry.and.returnValue(observableOf({ - customOrder: { - changed: false - } - })) + (service as any).getObjectEntry.and.returnValue(observableOf({})) }); it('should return false when there are no updates', () => { @@ -346,44 +277,4 @@ describe('ObjectUpdatesService', () => { }); }); - 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 c9a7f47e81..84f0f06035 100644 --- a/src/app/core/data/object-updates/object-updates.service.ts +++ b/src/app/core/data/object-updates/object-updates.service.ts @@ -8,16 +8,15 @@ import { Identifiable, OBJECT_UPDATES_TRASH_PATH, ObjectUpdatesEntry, - ObjectUpdatesState, OrderPage, + ObjectUpdatesState, VirtualMetadataSource } from './object-updates.reducer'; import { Observable } from 'rxjs'; import { - AddFieldUpdateAction, AddPageToCustomOrderAction, + AddFieldUpdateAction, DiscardObjectUpdatesAction, FieldChangeType, InitializeFieldsAction, - MoveFieldUpdateAction, ReinstateObjectUpdatesAction, RemoveFieldUpdateAction, SelectVirtualMetadataAction, @@ -25,11 +24,8 @@ import { SetValidFieldUpdateAction } from './object-updates.actions'; import { distinctUntilChanged, filter, map, switchMap } from 'rxjs/operators'; -import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util'; +import { hasNoValue, hasValue, isEmpty, isNotEmpty, isNotEmptyOperator } 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']); @@ -52,9 +48,7 @@ function virtualMetadataSourceSelector(url: string, source: string): MemoizedSel */ @Injectable() export class ObjectUpdatesService { - constructor(private store: Store, - private comparator: ArrayMoveChangeAnalyzer) { - + constructor(private store: Store) { } /** @@ -67,28 +61,6 @@ 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 @@ -153,7 +125,7 @@ export class ObjectUpdatesService { */ getFieldUpdatesExclusive(url: string, initialFields: Identifiable[]): Observable { const objectUpdates = this.getObjectEntry(url); - return objectUpdates.pipe(map((objectEntry) => { + return objectUpdates.pipe(isNotEmptyOperator(), map((objectEntry) => { const fieldUpdates: FieldUpdates = {}; for (const object of initialFields) { let fieldUpdate = objectEntry.fieldUpdates[object.uuid]; @@ -166,31 +138,6 @@ 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 @@ -260,19 +207,6 @@ 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 @@ -387,7 +321,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) || objectEntry.customOrder.changed))); + return this.getObjectEntry(url).pipe(map((objectEntry) => hasValue(objectEntry) && isNotEmpty(objectEntry.fieldUpdates))); } /** @@ -405,19 +339,4 @@ 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/process-files-response-parsing.service.ts b/src/app/core/data/process-files-response-parsing.service.ts new file mode 100644 index 0000000000..0fa7c66869 --- /dev/null +++ b/src/app/core/data/process-files-response-parsing.service.ts @@ -0,0 +1,41 @@ +import { ResponseParsingService } from './parsing.service'; +import { RestRequest } from './request.models'; +import { GenericSuccessResponse, RestResponse } from '../cache/response.models'; +import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model'; +import { isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { PaginatedList } from './paginated-list'; +import { PageInfo } from '../shared/page-info.model'; +import { Injectable } from '@angular/core'; +import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer'; +import { Bitstream } from '../shared/bitstream.model'; + +@Injectable() +/** + * A ResponseParsingService used to parse DSpaceRESTV2Response coming from the REST API to a GenericSuccessResponse + * containing a PaginatedList of a process's output files + */ +export class ProcessFilesResponseParsingService implements ResponseParsingService { + parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse { + const payload = data.payload; + + let page; + if (isNotEmpty(payload._embedded) && isNotEmpty(Object.keys(payload._embedded))) { + const bitstreams = new DSpaceSerializer(Bitstream).deserializeArray(payload._embedded[Object.keys(payload._embedded)[0]]); + + if (isNotEmpty(bitstreams)) { + page = new PaginatedList(Object.assign(new PageInfo(), { + elementsPerPage: bitstreams.length, + totalElements: bitstreams.length, + totalPages: 1, + currentPage: 1 + }), bitstreams); + } + } + + if (isEmpty(page)) { + page = new PaginatedList(new PageInfo(), []); + } + + return new GenericSuccessResponse(page, data.statusCode, data.statusText); + } +} diff --git a/src/app/core/data/processes/process-data.service.ts b/src/app/core/data/processes/process-data.service.ts new file mode 100644 index 0000000000..48c1d502cc --- /dev/null +++ b/src/app/core/data/processes/process-data.service.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@angular/core'; +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 { 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 { Process } from '../../../process-page/processes/process.model'; +import { dataService } from '../../cache/builders/build-decorators'; +import { PROCESS } from '../../../process-page/processes/process.resource-type'; +import { Observable } from 'rxjs/internal/Observable'; +import { map, switchMap } from 'rxjs/operators'; +import { ProcessFilesRequest, RestRequest } from '../request.models'; +import { configureRequest, filterSuccessfulResponses } from '../../shared/operators'; +import { GenericSuccessResponse } from '../../cache/response.models'; +import { PaginatedList } from '../paginated-list'; +import { Bitstream } from '../../shared/bitstream.model'; +import { RemoteData } from '../remote-data'; + +@Injectable() +@dataService(PROCESS) +export class ProcessDataService extends DataService { + protected linkPath = 'processes'; + + 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 a process his files + * @param processId The ID of the process + */ + getFilesEndpoint(processId: string): Observable { + return this.getBrowseEndpoint().pipe( + switchMap((href) => this.halService.getEndpoint('files', `${href}/${processId}`)) + ); + } + + /** + * Get a process his output files + * @param processId The ID of the process + */ + getFiles(processId: string): Observable>> { + const request$ = this.getFilesEndpoint(processId).pipe( + map((href) => new ProcessFilesRequest(this.requestService.generateRequestId(), href)), + configureRequest(this.requestService) + ); + const requestEntry$ = request$.pipe( + switchMap((request: RestRequest) => this.requestService.getByHref(request.href)) + ); + const payload$ = requestEntry$.pipe( + filterSuccessfulResponses(), + map((response: GenericSuccessResponse>) => response.payload) + ); + + return this.rdbService.toRemoteDataObservable(requestEntry$, payload$); + } +} diff --git a/src/app/core/data/processes/script-data.service.ts b/src/app/core/data/processes/script-data.service.ts new file mode 100644 index 0000000000..6600444ea0 --- /dev/null +++ b/src/app/core/data/processes/script-data.service.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@angular/core'; +import { DataService } from '../data.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 { Script } from '../../../process-page/scripts/script.model'; +import { ProcessParameter } from '../../../process-page/processes/process-parameter.model'; +import { find, map, switchMap } from 'rxjs/operators'; +import { URLCombiner } from '../../url-combiner/url-combiner'; +import { MultipartPostRequest, RestRequest } from '../request.models'; +import { RequestService } from '../request.service'; +import { Observable } from 'rxjs'; +import { RequestEntry } from '../request.reducer'; +import { dataService } from '../../cache/builders/build-decorators'; +import { SCRIPT } from '../../../process-page/scripts/script.resource-type'; + +@Injectable() +@dataService(SCRIPT) +export class ScriptDataService extends DataService