Merge remote-tracking branch 'upstream/master' into w2p-70504_New-user-registration

This commit is contained in:
Yana De Pauw
2020-06-29 10:42:16 +02:00
179 changed files with 8448 additions and 4608 deletions

View File

@@ -6,7 +6,7 @@ import { GroupsRegistryComponent } from './group-registry/groups-registry.compon
import { URLCombiner } from '../../core/url-combiner/url-combiner';
import { getAccessControlModulePath } from '../admin-routing.module';
const GROUP_EDIT_PATH = 'groups';
export const GROUP_EDIT_PATH = 'groups';
export function getGroupEditPath(id: string) {
return new URLCombiner(getAccessControlModulePath(), GROUP_EDIT_PATH, id).toString();

View File

@@ -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'}
},
])
]

View File

@@ -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'}
},
])
],

View File

@@ -11,7 +11,6 @@
<ds-pagination
*ngIf="(metadataSchemas | async)?.payload?.totalElements > 0"
[paginationOptions]="config"
[pageInfoState]="(metadataSchemas | async)?.payload"
[collectionSize]="(metadataSchemas | async)?.payload?.totalElements"
[hideGear]="true"
[hidePagerWhenSinglePage]="true"

View File

@@ -4,7 +4,7 @@ import { Observable, combineLatest as observableCombineLatest } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { map, take } from 'rxjs/operators';
import { filter, map, switchMap, take } from 'rxjs/operators';
import { hasValue } from '../../../shared/empty.util';
import { RestResponse } from '../../../core/cache/response.models';
import { zip } from 'rxjs/internal/observable/zip';
@@ -12,6 +12,8 @@ import { NotificationsService } from '../../../shared/notifications/notification
import { Route, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { MetadataSchema } from '../../../core/metadata/metadata-schema.model';
import { toFindListOptions } from '../../../shared/pagination/pagination.utils';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
@Component({
selector: 'ds-metadata-registry',
@@ -37,6 +39,11 @@ export class MetadataRegistryComponent {
pageSize: 25
});
/**
* Whether or not the list of MetadataSchemas needs an update
*/
needsUpdate$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(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$ = [];

View File

@@ -21,7 +21,8 @@ describe('MetadataSchemaFormComponent', () => {
const registryServiceStub = {
getActiveMetadataSchema: () => observableOf(undefined),
createOrUpdateMetadataSchema: (schema: MetadataSchema) => observableOf(schema),
cancelEditMetadataSchema: () => {}
cancelEditMetadataSchema: () => {},
clearMetadataSchemaRequests: () => observableOf(undefined)
};
const formBuilderServiceStub = {
createFormGroup: () => {

View File

@@ -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();
}
);
}

View File

@@ -30,6 +30,7 @@ describe('MetadataFieldFormComponent', () => {
createOrUpdateMetadataField: (field: MetadataField) => observableOf(field),
cancelEditMetadataField: () => {},
cancelEditMetadataSchema: () => {},
clearMetadataFieldRequests: () => observableOf(undefined)
};
const formBuilderServiceStub = {
createFormGroup: () => {

View File

@@ -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();
}
);
}

View File

@@ -1,36 +1,37 @@
<div class="container">
<div class="metadata-schema row">
<div class="col-12">
<div class="col-12" *ngVar="(metadataSchema$ | async) as schema">
<h2 id="header" class="border-bottom pb-2">{{'admin.registries.schema.head' | translate}}: "{{(metadataSchema | async)?.payload?.prefix}}"</h2>
<h2 id="header" class="border-bottom pb-2">{{'admin.registries.schema.head' | translate}}: "{{schema?.prefix}}"</h2>
<p id="description" class="pb-2">{{'admin.registries.schema.description' | translate:namespace }}</p>
<p id="description" class="pb-2">{{'admin.registries.schema.description' | translate:{ namespace: schema?.namespace } }}</p>
<ds-metadata-field-form
[metadataSchema]="(metadataSchema | async)?.payload"
[metadataSchema]="schema"
(submitForm)="forceUpdateFields()"></ds-metadata-field-form>
<h3>{{'admin.registries.schema.fields.head' | translate}}</h3>
<ds-pagination
*ngIf="(metadataFields | async)?.payload?.totalElements > 0"
[paginationOptions]="config"
[pageInfoState]="(metadataFields | async)?.payload"
[collectionSize]="(metadataFields | async)?.payload?.totalElements"
[hideGear]="false"
[hidePagerWhenSinglePage]="true"
(pageChange)="onPageChange($event)">
<div class="table-responsive">
<table id="metadata-fields" class="table table-striped table-hover">
<thead>
<ng-container *ngVar="(metadataFields$ | async)?.payload as fields">
<ds-pagination
*ngIf="fields?.totalElements > 0"
[paginationOptions]="config"
[pageInfoState]="fields"
[collectionSize]="fields?.totalElements"
[hideGear]="false"
[hidePagerWhenSinglePage]="true"
(pageChange)="onPageChange($event)">
<div class="table-responsive">
<table id="metadata-fields" class="table table-striped table-hover">
<thead>
<tr>
<th></th>
<th scope="col">{{'admin.registries.schema.fields.table.field' | translate}}</th>
<th scope="col">{{'admin.registries.schema.fields.table.scopenote' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let field of (metadataFields | async)?.payload?.page"
</thead>
<tbody>
<tr *ngFor="let field of fields?.page"
[ngClass]="{'table-primary' : isActive(field) | async}">
<td>
<label>
@@ -39,22 +40,23 @@
(change)="selectMetadataField(field, $event)">
</label>
</td>
<td class="selectable-row" (click)="editField(field)">{{(metadataSchema | async)?.payload?.prefix}}.{{field.element}}<label *ngIf="field.qualifier">.</label>{{field.qualifier}}</td>
<td class="selectable-row" (click)="editField(field)">{{schema?.prefix}}.{{field.element}}<label *ngIf="field.qualifier">.</label>{{field.qualifier}}</td>
<td class="selectable-row" (click)="editField(field)">{{field.scopeNote}}</td>
</tr>
</tbody>
</table>
</tbody>
</table>
</div>
</ds-pagination>
<div *ngIf="fields?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert">
{{'admin.registries.schema.fields.no-items' | translate}}
</div>
</ds-pagination>
<div *ngIf="(metadataFields | async)?.payload?.totalElements == 0" class="alert alert-info w-100 mb-2" role="alert">
{{'admin.registries.schema.fields.no-items' | translate}}
</div>
<div>
<button [routerLink]="['/admin/registries/metadata']" class="btn btn-primary">{{'admin.registries.schema.return' | translate}}</button>
<button *ngIf="(metadataFields | async)?.payload?.page?.length > 0" type="submit" class="btn btn-danger float-right" (click)="deleteFields()">{{'admin.registries.schema.fields.table.delete' | translate}}</button>
</div>
<div>
<button [routerLink]="['/admin/registries/metadata']" class="btn btn-primary">{{'admin.registries.schema.return' | translate}}</button>
<button *ngIf="fields?.page?.length > 0" type="submit" class="btn btn-danger float-right" (click)="deleteFields()">{{'admin.registries.schema.fields.table.delete' | translate}}</button>
</div>
</ng-container>
</div>
</div>

View File

@@ -22,6 +22,7 @@ import { RestResponse } from '../../../core/cache/response.models';
import { MetadataSchema } from '../../../core/metadata/metadata-schema.model';
import { MetadataField } from '../../../core/metadata/metadata-field.model';
import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils';
import { VarDirective } from '../../../shared/utils/var.directive';
describe('MetadataSchemaComponent', () => {
let comp: MetadataSchemaComponent;
@@ -124,7 +125,7 @@ describe('MetadataSchemaComponent', () => {
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule],
declarations: [MetadataSchemaComponent, PaginationComponent, EnumKeysPipe],
declarations: [MetadataSchemaComponent, PaginationComponent, EnumKeysPipe, VarDirective],
providers: [
{ provide: RegistryService, useValue: registryServiceStub },
{ provide: ActivatedRoute, useValue: activatedRouteStub },

View File

@@ -5,7 +5,7 @@ import { Observable, combineLatest as observableCombineLatest } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data';
import { PaginatedList } from '../../../core/data/paginated-list';
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
import { map, take } from 'rxjs/operators';
import { map, switchMap, take } from 'rxjs/operators';
import { hasValue } from '../../../shared/empty.util';
import { RestResponse } from '../../../core/cache/response.models';
import { zip } from 'rxjs/internal/observable/zip';
@@ -13,6 +13,10 @@ import { NotificationsService } from '../../../shared/notifications/notification
import { TranslateService } from '@ngx-translate/core';
import { MetadataField } from '../../../core/metadata/metadata-field.model';
import { MetadataSchema } from '../../../core/metadata/metadata-schema.model';
import { getFirstSucceededRemoteDataPayload } from '../../../core/shared/operators';
import { toFindListOptions } from '../../../shared/pagination/pagination.utils';
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
import { combineLatest } from 'rxjs/internal/observable/combineLatest';
@Component({
selector: 'ds-metadata-schema',
@@ -24,21 +28,15 @@ import { MetadataSchema } from '../../../core/metadata/metadata-schema.model';
* The admin can create, edit or delete metadata fields here.
*/
export class MetadataSchemaComponent implements OnInit {
/**
* The namespace of the metadata schema
*/
namespace;
/**
* The metadata schema
*/
metadataSchema: Observable<RemoteData<MetadataSchema>>;
metadataSchema$: Observable<MetadataSchema>;
/**
* A list of all the fields attached to this metadata schema
*/
metadataFields: Observable<RemoteData<PaginatedList<MetadataField>>>;
metadataFields$: Observable<RemoteData<PaginatedList<MetadataField>>>;
/**
* Pagination config used to display the list of metadata fields
@@ -49,6 +47,11 @@ export class MetadataSchemaComponent implements OnInit {
pageSizeOptions: [25, 50, 100, 200]
});
/**
* Whether or not the list of MetadataFields needs an update
*/
needsUpdate$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(true);
constructor(private registryService: RegistryService,
private route: ActivatedRoute,
private notificationsService: NotificationsService,
@@ -68,7 +71,7 @@ export class MetadataSchemaComponent implements OnInit {
* @param params
*/
initialize(params) {
this.metadataSchema = this.registryService.getMetadataSchemaByName(params.schemaName);
this.metadataSchema$ = this.registryService.getMetadataSchemaByName(params.schemaName).pipe(getFirstSucceededRemoteDataPayload());
this.updateFields();
}
@@ -78,18 +81,20 @@ export class MetadataSchemaComponent implements OnInit {
*/
onPageChange(event) {
this.config.currentPage = event;
this.updateFields();
this.forceUpdateFields();
}
/**
* Update the list of fields by fetching it from the rest api or cache
*/
private updateFields() {
this.metadataSchema.subscribe((schemaData) => {
const schema = schemaData.payload;
this.metadataFields = this.registryService.getMetadataFieldsBySchema(schema, this.config);
this.namespace = {namespace: schemaData.payload.namespace};
});
this.metadataFields$ = combineLatest(this.metadataSchema$, this.needsUpdate$).pipe(
switchMap(([schema, update]: [MetadataSchema, boolean]) => {
if (update) {
return this.registryService.getMetadataFieldsBySchema(schema, toFindListOptions(this.config));
}
})
);
}
/**
@@ -97,8 +102,7 @@ export class MetadataSchemaComponent implements OnInit {
* a new REST call
*/
public forceUpdateFields() {
this.registryService.clearMetadataFieldRequests().subscribe();
this.updateFields();
this.needsUpdate$.next(true);
}
/**
@@ -157,6 +161,7 @@ export class MetadataSchemaComponent implements OnInit {
* Delete all the selected metadata fields
*/
deleteFields() {
this.registryService.clearMetadataFieldRequests().subscribe();
this.registryService.getSelectedMetadataFields().pipe(take(1)).subscribe(
(fields) => {
const tasks$ = [];

View File

@@ -8,7 +8,7 @@ import { I18nBreadcrumbsService } from '../core/breadcrumbs/i18n-breadcrumbs.ser
import { URLCombiner } from '../core/url-combiner/url-combiner';
const REGISTRIES_MODULE_PATH = 'registries';
const ACCESS_CONTROL_MODULE_PATH = 'access-control';
export const ACCESS_CONTROL_MODULE_PATH = 'access-control';
export function getRegistriesModulePath() {
return new URLCombiner(getAdminModulePath(), REGISTRIES_MODULE_PATH).toString();

View File

@@ -29,6 +29,10 @@ import { ItemEditBitstreamDragHandleComponent } from './item-bitstreams/item-edi
import { PaginatedDragAndDropBitstreamListComponent } from './item-bitstreams/item-edit-bitstream-bundle/paginated-drag-and-drop-bitstream-list/paginated-drag-and-drop-bitstream-list.component';
import { VirtualMetadataComponent } from './virtual-metadata/virtual-metadata.component';
import { ItemVersionHistoryComponent } from './item-version-history/item-version-history.component';
import { ItemAuthorizationsComponent } from './item-authorizations/item-authorizations.component';
import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit/resource-policy-edit.component';
import { ResourcePolicyCreateComponent } from '../../shared/resource-policies/create/resource-policy-create.component';
import { ObjectValuesPipe } from '../../shared/utils/object-values-pipe';
/**
* Module that contains all components related to the Edit Item page administrator functionality
@@ -67,9 +71,13 @@ import { ItemVersionHistoryComponent } from './item-version-history/item-version
ItemMoveComponent,
ItemEditBitstreamDragHandleComponent,
VirtualMetadataComponent,
ItemAuthorizationsComponent,
ResourcePolicyEditComponent,
ResourcePolicyCreateComponent,
],
providers: [
BundleDataService
BundleDataService,
ObjectValuesPipe
]
})
export class EditItemPageModule {

View File

@@ -14,6 +14,12 @@ import { ItemMoveComponent } from './item-move/item-move.component';
import { ItemRelationshipsComponent } from './item-relationships/item-relationships.component';
import { I18nBreadcrumbResolver } from '../../core/breadcrumbs/i18n-breadcrumb.resolver';
import { ItemVersionHistoryComponent } from './item-version-history/item-version-history.component';
import { ItemAuthorizationsComponent } from './item-authorizations/item-authorizations.component';
import { ResourcePolicyTargetResolver } from '../../shared/resource-policies/resolvers/resource-policy-target.resolver';
import { ResourcePolicyResolver } from '../../shared/resource-policies/resolvers/resource-policy.resolver';
import { ResourcePolicyCreateComponent } from '../../shared/resource-policies/create/resource-policy-create.component';
import { ResourcePolicyEditComponent } from '../../shared/resource-policies/edit/resource-policy-edit.component';
import { I18nBreadcrumbsService } from '../../core/breadcrumbs/i18n-breadcrumbs.service';
export const ITEM_EDIT_WITHDRAW_PATH = 'withdraw';
export const ITEM_EDIT_REINSTATE_PATH = 'reinstate';
@@ -21,6 +27,7 @@ export const ITEM_EDIT_PRIVATE_PATH = 'private';
export const ITEM_EDIT_PUBLIC_PATH = 'public';
export const ITEM_EDIT_DELETE_PATH = 'delete';
export const ITEM_EDIT_MOVE_PATH = 'move';
export const ITEM_EDIT_AUTHORIZATIONS_PATH = 'authorizations';
/**
* Routing module that handles the routing for the Edit Item page administrator functionality
@@ -111,12 +118,43 @@ export const ITEM_EDIT_MOVE_PATH = 'move';
path: ITEM_EDIT_MOVE_PATH,
component: ItemMoveComponent,
data: { title: 'item.edit.move.title' },
},
{
path: ITEM_EDIT_AUTHORIZATIONS_PATH,
children: [
{
path: 'create',
resolve: {
resourcePolicyTarget: ResourcePolicyTargetResolver
},
component: ResourcePolicyCreateComponent,
data: { title: 'resource-policies.create.page.title' }
},
{
path: 'edit',
resolve: {
resourcePolicy: ResourcePolicyResolver
},
component: ResourcePolicyEditComponent,
data: { title: 'resource-policies.edit.page.title' }
},
{
path: '',
component: ItemAuthorizationsComponent,
data: { title: 'item.edit.authorizations.title' }
}
]
}
]
}
])
],
providers: []
providers: [
I18nBreadcrumbResolver,
I18nBreadcrumbsService,
ResourcePolicyResolver,
ResourcePolicyTargetResolver
]
})
export class EditItemPageRoutingModule {

View File

@@ -0,0 +1,13 @@
<div class="container">
<ds-alert [type]="'alert-info'" [content]="'item.edit.authorizations.heading'"></ds-alert>
<ds-resource-policies [resourceType]="'item'" [resourceUUID]="(getItemUUID() | async)"></ds-resource-policies>
<ng-container *ngFor="let bundle of (getItemBundles() | async); trackById">
<ds-resource-policies [resourceType]="'bundle'"
[resourceUUID]="bundle.id"></ds-resource-policies>
<ng-container *ngFor="let bitstream of (bundleBitstreamsMap.get(bundle.id) | async)?.page; trackById">
<ds-resource-policies [resourceType]="'bitstream'"
[resourceUUID]="bitstream.id"></ds-resource-policies>
</ng-container>
</ng-container>
</div>

View File

@@ -0,0 +1,183 @@
import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing';
import { Component, NO_ERRORS_SCHEMA } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { of as observableOf } from 'rxjs';
import { TranslateModule } from '@ngx-translate/core';
import { cold } from 'jasmine-marbles';
import { ItemAuthorizationsComponent } from './item-authorizations.component';
import { Bitstream } from '../../../core/shared/bitstream.model';
import { Bundle } from '../../../core/shared/bundle.model';
import { createMockRDPaginatedObs } from '../item-bitstreams/item-bitstreams.component.spec';
import { Item } from '../../../core/shared/item.model';
import { LinkService } from '../../../core/cache/builders/link.service';
import { getMockLinkService } from '../../../shared/mocks/link-service.mock';
import { createSuccessfulRemoteDataObject } from '../../../shared/remote-data.utils';
import { createTestComponent } from '../../../shared/testing/utils.test';
import { PaginatedList } from '../../../core/data/paginated-list';
import { PageInfo } from '../../../core/shared/page-info.model';
describe('ItemAuthorizationsComponent test suite', () => {
let comp: ItemAuthorizationsComponent;
let compAsAny: any;
let fixture: ComponentFixture<ItemAuthorizationsComponent>;
let de;
const linkService: any = getMockLinkService();
const bitstream1 = Object.assign(new Bitstream(), {
id: 'bitstream1',
uuid: 'bitstream1'
});
const bitstream2 = Object.assign(new Bitstream(), {
id: 'bitstream2',
uuid: 'bitstream2'
});
const bitstream3 = Object.assign(new Bitstream(), {
id: 'bitstream3',
uuid: 'bitstream3'
});
const bitstream4 = Object.assign(new Bitstream(), {
id: 'bitstream4',
uuid: 'bitstream4'
});
const bundle1 = Object.assign(new Bundle(), {
id: 'bundle1',
uuid: 'bundle1',
_links: {
self: { href: 'bundle1-selflink' }
},
bitstreams: createMockRDPaginatedObs([bitstream1, bitstream2])
});
const bundle2 = Object.assign(new Bundle(), {
id: 'bundle2',
uuid: 'bundle2',
_links: {
self: { href: 'bundle2-selflink' }
},
bitstreams: createMockRDPaginatedObs([bitstream3, bitstream4])
});
const bundles = [bundle1, bundle2];
const bitstreamList1: PaginatedList<Bitstream> = new PaginatedList(new PageInfo(), [bitstream1, bitstream2]);
const bitstreamList2: PaginatedList<Bitstream> = new PaginatedList(new PageInfo(), [bitstream3, bitstream4]);
const item = Object.assign(new Item(), {
uuid: 'item',
id: 'item',
_links: {
self: { href: 'item-selflink' }
},
bundles: createMockRDPaginatedObs([bundle1, bundle2])
});
const routeStub = {
data: observableOf({
item: createSuccessfulRemoteDataObject(item)
})
};
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
NoopAnimationsModule,
TranslateModule.forRoot()
],
declarations: [
ItemAuthorizationsComponent,
TestComponent
],
providers: [
{ provide: LinkService, useValue: linkService },
{ provide: ActivatedRoute, useValue: routeStub },
ItemAuthorizationsComponent
],
schemas: [
NO_ERRORS_SCHEMA
]
}).compileComponents();
}));
describe('', () => {
let testComp: TestComponent;
let testFixture: ComponentFixture<TestComponent>;
// synchronous beforeEach
beforeEach(() => {
const html = `
<ds-item-authorizations></ds-item-authorizations>`;
testFixture = createTestComponent(html, TestComponent) as ComponentFixture<TestComponent>;
testComp = testFixture.componentInstance;
});
afterEach(() => {
testFixture.destroy();
});
it('should create ItemAuthorizationsComponent', inject([ItemAuthorizationsComponent], (app: ItemAuthorizationsComponent) => {
expect(app).toBeDefined();
}));
});
describe('', () => {
beforeEach(() => {
fixture = TestBed.createComponent(ItemAuthorizationsComponent);
comp = fixture.componentInstance;
compAsAny = fixture.componentInstance;
linkService.resolveLink.and.callFake((object, link) => object);
fixture.detectChanges();
});
afterEach(() => {
comp = null;
compAsAny = null;
de = null;
fixture.destroy();
});
it('should init bundles and bitstreams map properly', () => {
expect(compAsAny.subs.length).toBe(2);
expect(compAsAny.bundles$.value).toEqual(bundles);
expect(compAsAny.bundleBitstreamsMap.has('bundle1')).toBeTruthy();
expect(compAsAny.bundleBitstreamsMap.has('bundle2')).toBeTruthy();
let bitstreamList = compAsAny.bundleBitstreamsMap.get('bundle1');
expect(bitstreamList).toBeObservable(cold('(a|)', {
a: bitstreamList1
}));
bitstreamList = compAsAny.bundleBitstreamsMap.get('bundle2');
expect(bitstreamList).toBeObservable(cold('(a|)', {
a: bitstreamList2
}));
});
it('should get the item UUID', () => {
expect(comp.getItemUUID()).toBeObservable(cold('(a|)', {
a: item.id
}));
});
it('should get the item\'s bundle', () => {
expect(comp.getItemBundles()).toBeObservable(cold('a', {
a: bundles
}));
});
});
});
// declare a test component
@Component({
selector: 'ds-test-cmp',
template: ``
})
class TestComponent {
}

View File

@@ -0,0 +1,155 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs';
import { catchError, filter, first, flatMap, map, take } from 'rxjs/operators';
import { PaginatedList } from '../../../core/data/paginated-list';
import {
getFirstSucceededRemoteDataPayload,
getFirstSucceededRemoteDataWithNotEmptyPayload
} from '../../../core/shared/operators';
import { Item } from '../../../core/shared/item.model';
import { followLink } from '../../../shared/utils/follow-link-config.model';
import { LinkService } from '../../../core/cache/builders/link.service';
import { Bundle } from '../../../core/shared/bundle.model';
import { hasValue, isNotEmpty } from '../../../shared/empty.util';
import { Bitstream } from '../../../core/shared/bitstream.model';
import { FindListOptions } from '../../../core/data/request.models';
/**
* Interface for a bundle's bitstream map entry
*/
interface BundleBitstreamsMapEntry {
id: string;
bitstreams: Observable<PaginatedList<Bitstream>>
}
@Component({
selector: 'ds-item-authorizations',
templateUrl: './item-authorizations.component.html'
})
/**
* Component that handles the item Authorizations
*/
export class ItemAuthorizationsComponent implements OnInit, OnDestroy {
/**
* A map that contains all bitstream of the item's bundles
* @type {Observable<Map<string, Observable<PaginatedList<Bitstream>>>>}
*/
public bundleBitstreamsMap: Map<string, Observable<PaginatedList<Bitstream>>> = new Map<string, Observable<PaginatedList<Bitstream>>>();
/**
* The list of bundle for the item
* @type {Observable<PaginatedList<Bundle>>}
*/
private bundles$: BehaviorSubject<Bundle[]> = new BehaviorSubject<Bundle[]>([]);
/**
* The target editing item
* @type {Observable<Item>}
*/
private item$: Observable<Item>;
/**
* Array to track all subscriptions and unsubscribe them onDestroy
* @type {Array}
*/
private subs: Subscription[] = [];
/**
* Initialize instance variables
*
* @param {LinkService} linkService
* @param {ActivatedRoute} route
*/
constructor(
private linkService: LinkService,
private route: ActivatedRoute
) {
}
/**
* Initialize the component, setting up the bundle and bitstream within the item
*/
ngOnInit(): void {
this.item$ = this.route.data.pipe(
map((data) => data.item),
getFirstSucceededRemoteDataWithNotEmptyPayload(),
map((item: Item) => this.linkService.resolveLink(
item,
followLink('bundles', new FindListOptions(), true, followLink('bitstreams'))
))
) as Observable<Item>;
const bundles$: Observable<PaginatedList<Bundle>> = this.item$.pipe(
filter((item: Item) => isNotEmpty(item.bundles)),
flatMap((item: Item) => item.bundles),
getFirstSucceededRemoteDataWithNotEmptyPayload(),
catchError((error) => {
console.error(error);
return observableOf(new PaginatedList(null, []))
})
);
this.subs.push(
bundles$.pipe(
take(1),
map((list: PaginatedList<Bundle>) => list.page)
).subscribe((bundles: Bundle[]) => {
this.bundles$.next(bundles);
}),
bundles$.pipe(
take(1),
flatMap((list: PaginatedList<Bundle>) => list.page),
map((bundle: Bundle) => ({ id: bundle.id, bitstreams: this.getBundleBitstreams(bundle) }))
).subscribe((entry: BundleBitstreamsMapEntry) => {
this.bundleBitstreamsMap.set(entry.id, entry.bitstreams)
})
)
}
/**
* Return the item's UUID
*/
getItemUUID(): Observable<string> {
return this.item$.pipe(
map((item: Item) => item.id),
first((UUID: string) => isNotEmpty(UUID))
)
}
/**
* Return all item's bundles
*
* @return an observable that emits all item's bundles
*/
getItemBundles(): Observable<Bundle[]> {
return this.bundles$.asObservable();
}
/**
* Return all bundle's bitstreams
*
* @return an observable that emits all item's bundles
*/
private getBundleBitstreams(bundle: Bundle): Observable<PaginatedList<Bitstream>> {
return bundle.bitstreams.pipe(
getFirstSucceededRemoteDataPayload(),
catchError((error) => {
console.error(error);
return observableOf(new PaginatedList(null, []))
})
)
}
/**
* Unsubscribe from all subscriptions
*/
ngOnDestroy(): void {
this.subs
.filter((subscription) => hasValue(subscription))
.forEach((subscription) => subscription.unsubscribe())
}
}

View File

@@ -36,7 +36,8 @@
<ds-item-edit-bitstream-bundle *ngFor="let bundle of bundles"
[bundle]="bundle"
[item]="item"
[columnSizes]="columnSizes">
[columnSizes]="columnSizes"
(dropObject)="dropBitstream(bundle, $event)">
</ds-item-edit-bitstream-bundle>
</div>
<div *ngIf="bundles?.length === 0"

View File

@@ -188,8 +188,21 @@ describe('ItemBitstreamsComponent', () => {
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', () => {
const event = {
fromIndex: 0,
toIndex: 50,
// tslint:disable-next-line:no-empty
finish: () => {}
};
beforeEach(() => {
comp.dropBitstream(bundle, event);
});
it('should send out a patch for the move operation', () => {
expect(bundleService.patch).toHaveBeenCalled();
});
});

View File

@@ -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

View File

@@ -17,5 +17,5 @@
</div>
</div>
</div>
<ds-paginated-drag-and-drop-bitstream-list [bundle]="bundle" [columnSizes]="columnSizes"></ds-paginated-drag-and-drop-bitstream-list>
<ds-paginated-drag-and-drop-bitstream-list [bundle]="bundle" [columnSizes]="columnSizes" (dropObject)="dropObject.emit($event)"></ds-paginated-drag-and-drop-bitstream-list>
</ng-template>

View File

@@ -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<any> = new EventEmitter<any>();
/**
* 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

View File

@@ -7,24 +7,29 @@
[collectionSize]="(objectsRD$ | async)?.payload?.totalElements"
[disableRouteParameterUpdate]="true"
(pageChange)="switchPage($event)">
<div [id]="bundle.id" class="bundle-bitstreams-list"
[ngClass]="{'mb-3': (objectsRD$ | async)?.payload?.totalElements > pageSize}"
*ngVar="((updates$ | async) | dsObjectValues) as updateValues" cdkDropList (cdkDropListDropped)="drop($event)">
<div class="row bitstream-row" *ngFor="let updateValue of updateValues" cdkDrag
[id]="updateValue.field.uuid"
[ngClass]="{
'table-warning': updateValue.changeType === 0,
'table-danger': updateValue.changeType === 2,
'table-success': updateValue.changeType === 1,
'bg-white': updateValue.changeType === undefined
<ng-container *ngIf="!(loading$ | async)">
<div [id]="bundle.id" class="bundle-bitstreams-list"
[ngClass]="{'mb-3': (objectsRD$ | async)?.payload?.totalElements > pageSize}"
*ngVar="(updates$ | async) as updates" cdkDropList (cdkDropListDropped)="drop($event)">
<ng-container *ngIf="updates">
<div class="row bitstream-row" *ngFor="let uuid of customOrder" cdkDrag
[id]="uuid"
[ngClass]="{
'table-warning': updates[uuid].changeType === 0,
'table-danger': updates[uuid].changeType === 2,
'table-success': updates[uuid].changeType === 1,
'bg-white': updates[uuid].changeType === undefined
}">
<ds-item-edit-bitstream [fieldUpdate]="updateValue"
[bundleUrl]="bundle.self"
[columnSizes]="columnSizes">
<div class="d-flex align-items-center" slot="drag-handle" cdkDragHandle>
<ds-item-edit-bitstream-drag-handle></ds-item-edit-bitstream-drag-handle>
<ds-item-edit-bitstream [fieldUpdate]="updates[uuid]"
[bundleUrl]="bundle.self"
[columnSizes]="columnSizes">
<div class="d-flex align-items-center" slot="drag-handle" cdkDragHandle>
<ds-item-edit-bitstream-drag-handle></ds-item-edit-bitstream-drag-handle>
</div>
</ds-item-edit-bitstream>
</div>
</ds-item-edit-bitstream>
</ng-container>
</div>
</div>
</ng-container>
<ds-loading *ngIf="(loading$ | async)" [message]="'loading.bitstreams' | translate"></ds-loading>
</ds-pagination>

View File

@@ -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<PaginatedDragAndDropBitstreamListComponent>;
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
]

View File

@@ -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')
))
);
})
);
}

View File

@@ -68,6 +68,7 @@ export class ItemStatusComponent implements OnInit {
The value is supposed to be a href for the button
*/
this.operations = [];
this.operations.push(new ItemOperation('authorizations', this.getCurrentUrl(item) + '/authorizations'));
this.operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper'));
if (item.isWithdrawn) {
this.operations.push(new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate'));

View File

@@ -7,6 +7,7 @@ import { Item } from '../core/shared/item.model';
import { hasValue } from '../shared/empty.util';
import { find } from 'rxjs/operators';
import { followLink } from '../shared/utils/follow-link-config.model';
import { FindListOptions } from '../core/data/request.models';
/**
* This class represents a resolver that requests a specific item before the route is activated
@@ -26,7 +27,7 @@ export class ItemPageResolver implements Resolve<RemoteData<Item>> {
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<RemoteData<Item>> {
return this.itemService.findById(route.params.id,
followLink('owningCollection'),
followLink('bundles'),
followLink('bundles', new FindListOptions(), true, followLink('bitstreams')),
followLink('relationships'),
followLink('version', undefined, true, followLink('versionhistory')),
).pipe(

View File

@@ -7,9 +7,9 @@
</div>
<div class="add">
<a class="btn btn-lg btn-primary mt-1 ml-2" [routerLink]="['/submit']" role="button">
<button class="btn btn-lg btn-primary mt-1 ml-2" (click)="openDialog()" role="button">
<i class="fa fa-plus-circle" aria-hidden="true"></i> {{'mydspace.new-submission' | translate}}
</a>
</button>
</div>
</div>

View File

@@ -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<MyDSpaceNewSubmissionComponent>;
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

View File

@@ -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<SubmissionState>,
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
*/

View File

@@ -33,7 +33,7 @@ export function getBitstreamModulePath() {
return `/${BITSTREAM_MODULE_PATH}`;
}
const ADMIN_MODULE_PATH = 'admin';
export const ADMIN_MODULE_PATH = 'admin';
export function getAdminModulePath() {
return `/${ADMIN_MODULE_PATH}`;

View File

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

View File

@@ -9,6 +9,9 @@ import { TranslateLoaderMock } from '../shared/testing/translate-loader.mock';
import { BreadcrumbConfig } from './breadcrumb/breadcrumb-config.model';
import { BreadcrumbsService } from '../core/breadcrumbs/breadcrumbs.service';
import { Breadcrumb } from './breadcrumb/breadcrumb.model';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { VarDirective } from '../shared/utils/var.directive';
import { getTestScheduler } from 'jasmine-marbles';
class TestBreadcrumbsService implements BreadcrumbsService<string> {
@@ -64,17 +67,16 @@ describe('BreadcrumbsComponent', () => {
beforeEach(async(() => {
init();
TestBed.configureTestingModule({
declarations: [BreadcrumbsComponent],
declarations: [BreadcrumbsComponent, VarDirective],
imports: [RouterTestingModule.withRoutes([]), TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateLoaderMock
}
})],
}), NgbModule],
providers: [
{ provide: ActivatedRoute, useValue: route }
]
{provide: ActivatedRoute, useValue: route}
], schemas: [NO_ERRORS_SCHEMA]
})
.compileComponents();
}));
@@ -92,14 +94,16 @@ describe('BreadcrumbsComponent', () => {
describe('ngOnInit', () => {
beforeEach(() => {
spyOn(component, 'resolveBreadcrumbs').and.returnValue(observableOf([]))
spyOn(component, 'resolveBreadcrumbs').and.returnValue(observableOf([]));
});
it('should call resolveBreadcrumb on init', () => {
router.events = observableOf(new NavigationEnd(0, '', ''));
component.ngOnInit();
fixture.detectChanges();
expect(component.resolveBreadcrumbs).toHaveBeenCalledWith(route.root);
})
});
});
describe('resolveBreadcrumbs', () => {

View File

@@ -1,9 +1,9 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { Breadcrumb } from './breadcrumb/breadcrumb.model';
import { hasNoValue, hasValue, isNotUndefined, isUndefined } from '../shared/empty.util';
import { hasNoValue, hasValue, isUndefined } from '../shared/empty.util';
import { filter, map, switchMap, tap } from 'rxjs/operators';
import { combineLatest, Observable, Subscription, of as observableOf } from 'rxjs';
import { combineLatest, Observable, of as observableOf, Subscription } from 'rxjs';
/**
* Component representing the breadcrumbs of a page
@@ -13,22 +13,17 @@ import { combineLatest, Observable, Subscription, of as observableOf } from 'rxj
templateUrl: './breadcrumbs.component.html',
styleUrls: ['./breadcrumbs.component.scss']
})
export class BreadcrumbsComponent implements OnInit, OnDestroy {
export class BreadcrumbsComponent implements OnInit {
/**
* List of breadcrumbs for this page
* Observable of the list of breadcrumbs for this page
*/
breadcrumbs: Breadcrumb[];
breadcrumbs$: Observable<Breadcrumb[]>;
/**
* Whether or not to show breadcrumbs on this page
*/
showBreadcrumbs: boolean;
/**
* Subscription to unsubscribe from on destroy
*/
subscription: Subscription;
constructor(
private route: ActivatedRoute,
private router: Router
@@ -39,14 +34,11 @@ export class BreadcrumbsComponent implements OnInit, OnDestroy {
* Sets the breadcrumbs on init for this page
*/
ngOnInit(): void {
this.subscription = this.router.events.pipe(
this.breadcrumbs$ = this.router.events.pipe(
filter((e): e is NavigationEnd => e instanceof NavigationEnd),
tap(() => this.reset()),
switchMap(() => this.resolveBreadcrumbs(this.route.root))
).subscribe((breadcrumbs) => {
this.breadcrumbs = breadcrumbs;
}
)
switchMap(() => this.resolveBreadcrumbs(this.route.root)),
);
}
/**
@@ -81,20 +73,10 @@ export class BreadcrumbsComponent implements OnInit, OnDestroy {
return !last ? this.resolveBreadcrumbs(route.firstChild) : observableOf([]);
}
/**
* Unsubscribe from subscription
*/
ngOnDestroy(): void {
if (hasValue(this.subscription)) {
this.subscription.unsubscribe();
}
}
/**
* Resets the state of the breadcrumbs
*/
reset() {
this.breadcrumbs = [];
this.showBreadcrumbs = true;
}
}

View File

@@ -1,9 +1,9 @@
import { TestBed } from '@angular/core/testing';
import { fakeAsync, flush, TestBed } from '@angular/core/testing';
import { provideMockActions } from '@ngrx/effects/testing';
import { Store } from '@ngrx/store';
import { Store, StoreModule } from '@ngrx/store';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { cold, hot } from 'jasmine-marbles';
import { Observable, of as observableOf, throwError as observableThrow } from 'rxjs';
import { AuthEffects } from './auth.effects';
@@ -29,41 +29,53 @@ import {
} from './auth.actions';
import { authMethodsMock, AuthServiceStub } from '../../shared/testing/auth-service.stub';
import { AuthService } from './auth.service';
import { AuthState } from './auth.reducer';
import { authReducer } from './auth.reducer';
import { AuthStatus } from './models/auth-status.model';
import { EPersonMock } from '../../shared/testing/eperson.mock';
import { AppState, storeModuleConfig } from '../../app.reducer';
import { StoreActionTypes } from '../../store.actions';
import { isAuthenticated, isAuthenticatedLoaded } from './selectors';
describe('AuthEffects', () => {
let authEffects: AuthEffects;
let actions: Observable<any>;
let authServiceStub;
const store: Store<AuthState> = jasmine.createSpyObj('store', {
/* tslint:disable:no-empty */
dispatch: {},
/* tslint:enable:no-empty */
select: observableOf(true)
});
let initialState;
let token;
let store: MockStore<AppState>;
function init() {
authServiceStub = new AuthServiceStub();
token = authServiceStub.getToken();
initialState = {
core: {
auth: {
authenticated: false,
loaded: false,
loading: false,
authMethods: []
}
}
};
}
beforeEach(() => {
init();
TestBed.configureTestingModule({
imports: [
StoreModule.forRoot({ auth: authReducer }, storeModuleConfig)
],
providers: [
AuthEffects,
provideMockStore({ initialState }),
{ provide: AuthService, useValue: authServiceStub },
{ provide: Store, useValue: store },
provideMockActions(() => actions),
// other providers
],
});
authEffects = TestBed.get(AuthEffects);
store = TestBed.get(Store);
});
describe('authenticate$', () => {
@@ -138,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: {
@@ -151,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$', () => {
@@ -362,4 +381,40 @@ describe('AuthEffects', () => {
});
})
});
describe('clearInvalidTokenOnRehydrate$', () => {
beforeEach(() => {
store.overrideSelector(isAuthenticated, false);
});
describe('when auth loaded is false', () => {
it('should not call removeToken method', (done) => {
store.overrideSelector(isAuthenticatedLoaded, false);
actions = hot('--a-|', { a: { type: StoreActionTypes.REHYDRATE } });
spyOn(authServiceStub, 'removeToken');
authEffects.clearInvalidTokenOnRehydrate$.subscribe(() => {
expect(authServiceStub.removeToken).not.toHaveBeenCalled();
});
done();
});
});
describe('when auth loaded is true', () => {
it('should call removeToken method', fakeAsync(() => {
store.overrideSelector(isAuthenticatedLoaded, true);
actions = hot('--a-|', { a: { type: StoreActionTypes.REHYDRATE } });
spyOn(authServiceStub, 'removeToken');
authEffects.clearInvalidTokenOnRehydrate$.subscribe(() => {
expect(authServiceStub.removeToken).toHaveBeenCalled();
flush();
});
}));
});
});
});

View File

@@ -1,20 +1,18 @@
import { Observable, of as observableOf } from 'rxjs';
import { catchError, debounceTime, filter, map, switchMap, take, tap } from 'rxjs/operators';
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 @ngrx
import { Actions, Effect, ofType } from '@ngrx/effects';
import { Action, select, Store } from '@ngrx/store';
// import services
import { AuthService } from './auth.service';
import { EPerson } from '../eperson/models/eperson.model';
import { AuthStatus } from './models/auth-status.model';
import { AuthTokenInfo } from './models/auth-token-info.model';
import { AppState } from '../../app.reducer';
import { isAuthenticated } from './selectors';
import { isAuthenticated, isAuthenticatedLoaded } from './selectors';
import { StoreActionTypes } from '../../store.actions';
import { AuthMethod } from './models/auth.method';
// import actions
@@ -64,7 +62,6 @@ export class AuthEffects {
@Effect()
public authenticateSuccess$: Observable<Action> = this.actions$.pipe(
ofType(AuthActionTypes.AUTHENTICATE_SUCCESS),
tap((action: AuthenticationSuccessAction) => this.authService.storeToken(action.payload)),
map((action: AuthenticationSuccessAction) => new AuthenticatedAction(action.payload))
);
@@ -81,6 +78,7 @@ export class AuthEffects {
@Effect()
public authenticatedSuccess$: Observable<Action> = this.actions$.pipe(
ofType(AuthActionTypes.AUTHENTICATED_SUCCESS),
tap((action: AuthenticatedSuccessAction) => this.authService.storeToken(action.payload.authToken)),
map((action: AuthenticatedSuccessAction) => new RetrieveAuthenticatedEpersonAction(action.payload.userHref))
);
@@ -172,10 +170,11 @@ export class AuthEffects {
public clearInvalidTokenOnRehydrate$: Observable<any> = this.actions$.pipe(
ofType(StoreActionTypes.REHYDRATE),
switchMap(() => {
return this.store.pipe(
select(isAuthenticated),
const isLoaded$ = this.store.pipe(select(isAuthenticatedLoaded));
const authenticated$ = this.store.pipe(select(isAuthenticated));
return observableCombineLatest(isLoaded$, authenticated$).pipe(
take(1),
filter((authenticated) => !authenticated),
filter(([loaded, authenticated]) => loaded && !authenticated),
tap(() => this.authService.removeToken()),
tap(() => this.authService.resetAuthenticationError())
);

View File

@@ -48,7 +48,7 @@ describe(`AuthInterceptor`, () => {
describe('when has a valid token', () => {
it('should not add an Authorization header when were sending a HTTP request to \'authn\' endpoint', () => {
it('should not add an Authorization header when were sending a HTTP request to \'authn\' endpoint that is not the logout endpoint', () => {
service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/authn/login', 'password=password&user=user').subscribe((response) => {
expect(response).toBeTruthy();
});
@@ -58,8 +58,19 @@ describe(`AuthInterceptor`, () => {
const token = httpRequest.request.headers.get('authorization');
expect(token).toBeNull();
});
it('should add an Authorization header when were sending a HTTP request to the\'authn/logout\' endpoint', () => {
service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/authn/logout', 'test').subscribe((response) => {
expect(response).toBeTruthy();
});
it('should add an Authorization header when were sending a HTTP request to \'authn\' endpoint', () => {
const httpRequest = httpMock.expectOne(`dspace-spring-rest/api/authn/logout`);
expect(httpRequest.request.headers.has('authorization'));
const token = httpRequest.request.headers.get('authorization');
expect(token).toBe('Bearer token_test');
});
it('should add an Authorization header when were sending a HTTP request to a non-\'authn\' endpoint', () => {
service.request(RestRequestMethod.POST, 'dspace-spring-rest/api/submission/workspaceitems', 'test').subscribe((response) => {
expect(response).toBeTruthy();
});

View File

@@ -221,7 +221,7 @@ export class AuthInterceptor implements HttpInterceptor {
// Redirect to the login route
this.store.dispatch(new RedirectWhenTokenExpiredAction('auth.messages.expired'));
return observableOf(null);
} else if (!this.isAuthRequest(req) && isNotEmpty(token)) {
} else if ((!this.isAuthRequest(req) || this.isLogoutResponse(req)) && isNotEmpty(token)) {
// Intercept a request that is not to the authentication endpoint
authService.isTokenExpiring().pipe(
filter((isExpiring) => isExpiring))

View File

@@ -8,7 +8,9 @@ import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-con
/**
* The class that resolves the BreadcrumbConfig object for a Collection
*/
@Injectable()
@Injectable({
providedIn: 'root'
})
export class CollectionBreadcrumbResolver extends DSOBreadcrumbResolver<Collection> {
constructor(protected breadcrumbService: DSOBreadcrumbsService, protected dataService: CollectionDataService) {
super(breadcrumbService, dataService);

View File

@@ -8,7 +8,9 @@ import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-con
/**
* The class that resolves the BreadcrumbConfig object for a Community
*/
@Injectable()
@Injectable({
providedIn: 'root'
})
export class CommunityBreadcrumbResolver extends DSOBreadcrumbResolver<Community> {
constructor(protected breadcrumbService: DSOBreadcrumbsService, protected dataService: CommunityDataService) {
super(breadcrumbService, dataService);

View File

@@ -13,7 +13,9 @@ import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
/**
* The class that resolves the BreadcrumbConfig object for a DSpaceObject
*/
@Injectable()
@Injectable({
providedIn: 'root'
})
export abstract class DSOBreadcrumbResolver<T extends ChildHALResource & DSpaceObject> implements Resolve<BreadcrumbConfig<T>> {
constructor(protected breadcrumbService: DSOBreadcrumbsService, protected dataService: DataService<T>) {
}

View File

@@ -15,7 +15,9 @@ import { Injectable } from '@angular/core';
/**
* Service to calculate DSpaceObject breadcrumbs for a single part of the route
*/
@Injectable()
@Injectable({
providedIn: 'root'
})
export class DSOBreadcrumbsService implements BreadcrumbsService<ChildHALResource & DSpaceObject> {
constructor(
private linkService: LinkService,

View File

@@ -28,7 +28,8 @@ export class DSONameService {
return dso.firstMetadataValue('organization.legalName');
},
Default: (dso: DSpaceObject): string => {
return dso.firstMetadataValue('dc.title');
// If object doesn't have dc.title metadata use name property
return dso.firstMetadataValue('dc.title') || dso.name;
}
};

View File

@@ -14,7 +14,7 @@ describe('I18nBreadcrumbResolver', () => {
});
it('should resolve the breadcrumb config', () => {
const resolvedConfig = resolver.resolve({ data: { breadcrumbKey: i18nKey }, url: [path] } as any, {} as any);
const resolvedConfig = resolver.resolve({ data: { breadcrumbKey: i18nKey }, url: [path], pathFromRoot: [{ url: [path] }] } as any, {} as any);
const expectedConfig = { provider: i18nBreadcrumbService, key: i18nKey, url: path };
expect(resolvedConfig).toEqual(expectedConfig);
});

View File

@@ -7,7 +7,9 @@ import { hasNoValue } from '../../shared/empty.util';
/**
* The class that resolves a BreadcrumbConfig object with an i18n key string for a route
*/
@Injectable()
@Injectable({
providedIn: 'root'
})
export class I18nBreadcrumbResolver implements Resolve<BreadcrumbConfig<string>> {
constructor(protected breadcrumbService: I18nBreadcrumbsService) {
}
@@ -23,7 +25,17 @@ export class I18nBreadcrumbResolver implements Resolve<BreadcrumbConfig<string>>
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 = this.getResolvedUrl(route);
return { provider: this.breadcrumbService, key: key, url: fullPath };
}
/**
* Resolve the full URL of an ActivatedRouteSnapshot
* @param route
*/
getResolvedUrl(route: ActivatedRouteSnapshot): string {
return route.pathFromRoot
.map((v) => v.url.map((segment) => segment.toString()).join('/'))
.join('/');
}
}

View File

@@ -11,7 +11,9 @@ export const BREADCRUMB_MESSAGE_POSTFIX = '.breadcrumbs';
/**
* Service to calculate i18n breadcrumbs for a single part of the route
*/
@Injectable()
@Injectable({
providedIn: 'root'
})
export class I18nBreadcrumbsService implements BreadcrumbsService<string> {
/**

View File

@@ -8,7 +8,9 @@ import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-con
/**
* The class that resolves the BreadcrumbConfig object for an Item
*/
@Injectable()
@Injectable({
providedIn: 'root'
})
export class ItemBreadcrumbResolver extends DSOBreadcrumbResolver<Item> {
constructor(protected breadcrumbService: DSOBreadcrumbsService, protected dataService: ItemDataService) {
super(breadcrumbService, dataService);

View File

@@ -2,7 +2,7 @@
/**
* Class representing a query parameter (query?fieldName=fieldValue) used in FindListOptions object
*/
export class SearchParam {
export class RequestParam {
constructor(public fieldName: string, public fieldValue: any) {
}

View File

@@ -6,9 +6,6 @@ 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';
@@ -41,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
*/

View File

@@ -69,16 +69,11 @@ 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 './data/resource-policy.service';
import { ResourcePolicyService } from './resource-policy/resource-policy.service';
import { SearchResponseParsingService } from './data/search-response-parsing.service';
import { SiteDataService } from './data/site-data.service';
import { DSpaceRESTv2Service } from './dspace-rest-v2/dspace-rest-v2.service';
@@ -116,7 +111,7 @@ import { RelationshipType } from './shared/item-relationships/relationship-type.
import { Relationship } from './shared/item-relationships/relationship.model';
import { Item } from './shared/item.model';
import { License } from './shared/license.model';
import { ResourcePolicy } from './shared/resource-policy.model';
import { ResourcePolicy } from './resource-policy/models/resource-policy.model';
import { SearchConfigurationService } from './shared/search/search-configuration.service';
import { SearchFilterService } from './shared/search/search-filter.service';
import { SearchService } from './shared/search/search.service';
@@ -145,8 +140,9 @@ import { Version } from './shared/version.model';
import { VersionHistory } from './shared/version-history.model';
import { WorkflowActionDataService } from './data/workflow-action-data.service';
import { WorkflowAction } from './tasks/models/workflow-action-object.model';
import { EpersonRegistrationService } from './data/eperson-registration.service';
import { Registration } from './shared/registration.model';
import { MetadataSchemaDataService } from './data/metadata-schema-data.service';
import { MetadataFieldDataService } from './data/metadata-field-data.service';
/**
* When not in production, endpoint responses can be mocked for testing purposes
@@ -203,9 +199,6 @@ const PROVIDERS = [
FacetValueResponseParsingService,
FacetValueMapResponseParsingService,
FacetConfigResponseParsingService,
RegistryMetadataschemasResponseParsingService,
RegistryMetadatafieldsResponseParsingService,
RegistryBitstreamformatsResponseParsingService,
MappedCollectionsReponseParsingService,
DebugResponseParsingService,
SearchResponseParsingService,
@@ -225,8 +218,6 @@ const PROVIDERS = [
JsonPatchOperationsBuilder,
AuthorityService,
IntegrationResponseParsingService,
MetadataschemaParsingService,
MetadatafieldParsingService,
UploaderService,
UUIDService,
NotificationsService,
@@ -266,6 +257,8 @@ const PROVIDERS = [
LicenseDataService,
ItemTypeDataService,
WorkflowActionDataService,
MetadataSchemaDataService,
MetadataFieldDataService,
// register AuthInterceptor as HttpInterceptor
{
provide: HTTP_INTERCEPTORS,

View File

@@ -88,10 +88,12 @@ export class BundleDataService extends DataService<Bundle> {
/**
* Get the bitstreams endpoint for a bundle
* @param bundleId
* @param searchOptions
*/
getBitstreamsEndpoint(bundleId: string): Observable<string> {
getBitstreamsEndpoint(bundleId: string, searchOptions?: PaginatedSearchOptions): Observable<string> {
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<Bundle> {
* @param linksToFollow The {@link FollowLinkConfig}s for the request
*/
getBitstreams(bundleId: string, searchOptions?: PaginatedSearchOptions, ...linksToFollow: Array<FollowLinkConfig<Bitstream>>): Observable<RemoteData<PaginatedList<Bitstream>>> {
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) => {

View File

@@ -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<PaginatedList<Colletion>> 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<PaginatedList<Colletion>> 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')

View File

@@ -12,7 +12,7 @@ import { PaginatedSearchOptions } from '../../shared/search/paginated-search-opt
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { dataService } from '../cache/builders/build-decorators';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { SearchParam } from '../cache/models/search-param.model';
import { RequestParam } from '../cache/models/request-param.model';
import { ObjectCacheService } from '../cache/object-cache.service';
import { ContentSourceSuccessResponse, RestResponse } from '../cache/response.models';
import { CoreState } from '../core.reducers';
@@ -72,14 +72,18 @@ export class CollectionDataService extends ComColDataService<Collection> {
/**
* 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<RemoteData<PaginatedList<Collection>>>
* collection list
*/
getAuthorizedCollection(options: FindListOptions = {}): Observable<RemoteData<PaginatedList<Collection>>> {
const searchHref = 'findAuthorized';
getAuthorizedCollection(query: string, options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<Collection>>): Observable<RemoteData<PaginatedList<Collection>>> {
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<PaginatedList<Collection>>) => !collections.isResponsePending));
}
@@ -87,14 +91,18 @@ export class CollectionDataService extends ComColDataService<Collection> {
* 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<RemoteData<PaginatedList<Collection>>>
* collection list
*/
getAuthorizedCollectionByCommunity(communityId: string, options: FindListOptions = {}): Observable<RemoteData<PaginatedList<Collection>>> {
const searchHref = 'findAuthorizedByCommunity';
getAuthorizedCollectionByCommunity(communityId: string, query: string, options: FindListOptions = {}): Observable<RemoteData<PaginatedList<Collection>>> {
const searchHref = 'findSubmitAuthorizedByCommunity';
options = Object.assign({}, options, {
searchParams: [new SearchParam('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<Collection> {
* true if the user has at least one collection to submit to
*/
hasAuthorizedCollection(): Observable<boolean> {
const searchHref = 'findAuthorized';
const searchHref = 'findSubmitAuthorized';
const options = new FindListOptions();
options.elementsPerPage = 1;

View File

@@ -20,7 +20,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { getClassForType } from '../cache/builders/build-decorators';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { SearchParam } from '../cache/models/search-param.model';
import { RequestParam } from '../cache/models/request-param.model';
import { CacheableObject } from '../cache/object-cache.reducer';
import { ObjectCacheService } from '../cache/object-cache.service';
import { ErrorResponse, RestResponse } from '../cache/response.models';
@@ -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<T extends CacheableObject> {
protected abstract requestService: RequestService;
@@ -111,7 +112,7 @@ export abstract class DataService<T extends CacheableObject> {
result$ = this.getSearchEndpoint(searchMethod);
if (hasValue(options.searchParams)) {
options.searchParams.forEach((param: SearchParam) => {
options.searchParams.forEach((param: RequestParam) => {
args.push(`${param.fieldName}=${param.fieldValue}`);
})
}
@@ -153,6 +154,33 @@ export abstract class DataService<T extends CacheableObject> {
}
}
/**
* Turn an array of RequestParam into a query string and combine it with the given HREF
*
* @param href The HREF to which the query string should be appended
* @param params Array with additional params to combine with query string
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*
* @return {Observable<string>}
* Return an observable that emits created HREF
*/
protected buildHrefWithParams(href: string, params: RequestParam[], ...linksToFollow: Array<FollowLinkConfig<T>>): string {
let args = [];
if (hasValue(params)) {
params.forEach((param: RequestParam) => {
args.push(`${param.fieldName}=${param.fieldValue}`);
})
}
args = this.addEmbedParams(args, ...linksToFollow);
if (isNotEmpty(args)) {
return new URLCombiner(href, `?${args.join('&')}`).toString();
} else {
return href;
}
}
/**
* Adds the embed options to the link for the request
* @param args params for the query string
@@ -293,9 +321,9 @@ export abstract class DataService<T extends CacheableObject> {
* @param searchMethod The search method for the object
*/
protected getSearchEndpoint(searchMethod: string): Observable<string> {
return this.halService.getEndpoint(`${this.linkPath}/search`).pipe(
return this.halService.getEndpoint(this.linkPath).pipe(
filter((href: string) => isNotEmpty(href)),
map((href: string) => `${href}/${searchMethod}`));
map((href: string) => `${href}/search/${searchMethod}`));
}
/**
@@ -316,7 +344,9 @@ export abstract class DataService<T extends CacheableObject> {
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);
}
@@ -354,6 +384,28 @@ export abstract class DataService<T extends CacheableObject> {
);
}
/**
* Send a PUT request for the specified object
*
* @param object The object to send a put request for.
*/
put(object: T): Observable<RemoteData<T>> {
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
@@ -380,15 +432,15 @@ export abstract class DataService<T extends CacheableObject> {
*
* @param {DSpaceObject} dso
* The object to create
* @param {string} parentUUID
* The UUID of the parent to create the new object under
* @param {RequestParam[]} params
* Array with additional params to combine with query string
*/
create(dso: T, parentUUID: string): Observable<RemoteData<T>> {
create(dso: T, ...params: RequestParam[]): Observable<RemoteData<T>> {
const requestId = this.requestService.generateRequestId();
const endpoint$ = this.halService.getEndpoint(this.linkPath).pipe(
isNotEmptyOperator(),
distinctUntilChanged(),
map((endpoint: string) => parentUUID ? `${endpoint}?parent=${parentUUID}` : endpoint)
map((endpoint: string) => this.buildHrefWithParams(endpoint, params))
);
const serializedDso = new DSpaceSerializer(getClassForType((dso as any).type)).serialize(dso);
@@ -479,7 +531,7 @@ export abstract class DataService<T extends CacheableObject> {
const requestId = this.deleteAndReturnRequestId(dsoID, copyVirtualMetadata);
return this.requestService.getByUUID(requestId).pipe(
find((request: RequestEntry) => request.completed),
find((request: RequestEntry) => isNotEmpty(request) && request.completed),
map((request: RequestEntry) => request.response.isSuccessful)
);
}

View File

@@ -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();
});
});
});
});

View File

@@ -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<MetadataField> {
protected linkPath = 'metadatafields';
protected searchBySchemaLinkPath = 'bySchema';
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected halService: HALEndpointService,
protected objectCache: ObjectCacheService,
protected comparator: DefaultChangeAnalyzer<MetadataField>,
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<FollowLinkConfig<MetadataField>>) {
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<RemoteData<MetadataField>> {
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<string> {
return this.getBrowseEndpoint().pipe(
tap((href: string) => {
this.requestService.removeByHrefSubstring(href);
})
);
}
}

View File

@@ -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();
});
});
});
});

View File

@@ -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<MetadataSchema> {
protected linkPath = 'metadataschemas';
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: ChangeAnalyzer<MetadataSchema>) {
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<MetadataSchema> {
protected linkPath = 'metadataschemas';
constructor(
protected requestService: RequestService,
@@ -50,6 +34,35 @@ export class MetadataSchemaDataService {
protected comparator: DefaultChangeAnalyzer<MetadataSchema>,
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<RemoteData<MetadataSchema>> {
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<string> {
return this.getBrowseEndpoint().pipe(
tap((href: string) => this.requestService.removeByHrefSubstring(href))
);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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<CoreState>(undefined, undefined, undefined);
spyOn(store, 'dispatch');
service = new ObjectUpdatesService(store, new ArrayMoveChangeAnalyzer<string>());
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();
});
});
});
});

View File

@@ -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<CoreState, ObjectUpdatesState> {
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<CoreState>,
private comparator: ArrayMoveChangeAnalyzer<string>) {
constructor(private store: Store<CoreState>) {
}
/**
@@ -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<FieldUpdates> {
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<FieldUpdates> {
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<boolean> {
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<Date> {
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<MoveOperation[]> {
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)))
)
);
}
}

View File

@@ -1,41 +0,0 @@
import { PageInfo } from '../shared/page-info.model';
import { DSOResponseParsingService } from './dso-response-parsing.service';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import {
RegistryBitstreamformatsSuccessResponse
} from '../cache/response.models';
import { RegistryBitstreamformatsResponseParsingService } from './registry-bitstreamformats-response-parsing.service';
describe('RegistryBitstreamformatsResponseParsingService', () => {
let service: RegistryBitstreamformatsResponseParsingService;
const mockDSOParser = Object.assign({
processPageInfo: () => new PageInfo()
}) as DSOResponseParsingService;
const data = Object.assign({
payload: {
_embedded: {
bitstreamformats: [
{
uuid: 'uuid-1',
description: 'a description'
},
{
uuid: 'uuid-2',
description: 'another description'
},
]
}
}
}) as DSpaceRESTV2Response;
beforeEach(() => {
service = new RegistryBitstreamformatsResponseParsingService(mockDSOParser);
});
it('should parse the data correctly', () => {
const response = service.parse(null, data);
expect(response.constructor).toBe(RegistryBitstreamformatsSuccessResponse);
});
});

View File

@@ -1,25 +0,0 @@
import { Injectable } from '@angular/core';
import { RegistryBitstreamformatsSuccessResponse, 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 { RegistryBitstreamformatsResponse } from '../registry/registry-bitstreamformats-response.model';
import { DSOResponseParsingService } from './dso-response-parsing.service';
import { ResponseParsingService } from './parsing.service';
import { RestRequest } from './request.models';
@Injectable()
export class RegistryBitstreamformatsResponseParsingService implements ResponseParsingService {
constructor(private dsoParser: DSOResponseParsingService) {
}
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
const payload = data.payload;
const bitstreamformats = payload._embedded.bitstreamformats;
payload.bitstreamformats = bitstreamformats;
const deserialized = new DSpaceSerializer(RegistryBitstreamformatsResponse).deserialize(payload);
return new RegistryBitstreamformatsSuccessResponse(deserialized, data.statusCode, data.statusText, this.dsoParser.processPageInfo(data.payload.page));
}
}

View File

@@ -1,68 +0,0 @@
import { PageInfo } from '../shared/page-info.model';
import { DSOResponseParsingService } from './dso-response-parsing.service';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import {
RegistryMetadatafieldsSuccessResponse
} from '../cache/response.models';
import { RegistryMetadatafieldsResponseParsingService } from './registry-metadatafields-response-parsing.service';
describe('RegistryMetadatafieldsResponseParsingService', () => {
let service: RegistryMetadatafieldsResponseParsingService;
const mockDSOParser = Object.assign({
processPageInfo: () => new PageInfo()
}) as DSOResponseParsingService;
const data = Object.assign({
payload: {
_embedded: {
metadatafields: [
{
id: 1,
element: 'element',
qualifier: 'qualifier',
scopeNote: 'a scope note',
_embedded: {
schema: {
id: 1,
prefix: 'test',
namespace: 'test namespace'
}
}
},
{
id: 2,
element: 'secondelement',
qualifier: 'secondqualifier',
scopeNote: 'a second scope note',
_embedded: {
schema: {
id: 1,
prefix: 'test',
namespace: 'test namespace'
}
}
},
]
}
}
}) as DSpaceRESTV2Response;
const emptyData = Object.assign({
payload: {}
}) as DSpaceRESTV2Response;
beforeEach(() => {
service = new RegistryMetadatafieldsResponseParsingService(mockDSOParser);
});
it('should parse the data correctly', () => {
const response = service.parse(null, data);
expect(response.constructor).toBe(RegistryMetadatafieldsSuccessResponse);
});
it('should not produce an error and parse the data correctly when the data is empty', () => {
const response = service.parse(null, emptyData);
expect(response.constructor).toBe(RegistryMetadatafieldsSuccessResponse);
});
});

View File

@@ -1,34 +0,0 @@
import { Injectable } from '@angular/core';
import { hasValue } from '../../shared/empty.util';
import { RegistryMetadatafieldsSuccessResponse, RestResponse } from '../cache/response.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer';
import { RegistryMetadatafieldsResponse } from '../registry/registry-metadatafields-response.model';
import { DSOResponseParsingService } from './dso-response-parsing.service';
import { ResponseParsingService } from './parsing.service';
import { RestRequest } from './request.models';
@Injectable()
export class RegistryMetadatafieldsResponseParsingService implements ResponseParsingService {
constructor(private dsoParser: DSOResponseParsingService) {
}
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
const payload = data.payload;
let metadatafields = [];
if (hasValue(payload._embedded)) {
metadatafields = payload._embedded.metadatafields;
metadatafields.forEach((field) => {
field.schema = field._embedded.schema;
});
}
payload.metadatafields = metadatafields;
const deserialized = new DSpaceSerializer(RegistryMetadatafieldsResponse).deserialize(payload);
return new RegistryMetadatafieldsSuccessResponse(deserialized, data.statusCode, data.statusText, this.dsoParser.processPageInfo(data.payload));
}
}

View File

@@ -1,50 +0,0 @@
import { RegistryMetadataschemasResponseParsingService } from './registry-metadataschemas-response-parsing.service';
import { PageInfo } from '../shared/page-info.model';
import { DSOResponseParsingService } from './dso-response-parsing.service';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { RegistryMetadataschemasSuccessResponse } from '../cache/response.models';
describe('RegistryMetadataschemasResponseParsingService', () => {
let service: RegistryMetadataschemasResponseParsingService;
const mockDSOParser = Object.assign({
processPageInfo: () => new PageInfo()
}) as DSOResponseParsingService;
const data = Object.assign({
payload: {
_embedded: {
metadataschemas: [
{
id: 1,
prefix: 'test',
namespace: 'test namespace'
},
{
id: 2,
prefix: 'second',
namespace: 'second test namespace'
}
]
}
}
}) as DSpaceRESTV2Response;
const emptyData = Object.assign({
payload: {}
}) as DSpaceRESTV2Response;
beforeEach(() => {
service = new RegistryMetadataschemasResponseParsingService(mockDSOParser);
});
it('should parse the data correctly', () => {
const response = service.parse(null, data);
expect(response.constructor).toBe(RegistryMetadataschemasSuccessResponse);
});
it('should not produce an error and parse the data correctly when the data is empty', () => {
const response = service.parse(null, emptyData);
expect(response.constructor).toBe(RegistryMetadataschemasSuccessResponse);
});
});

View File

@@ -1,29 +0,0 @@
import { Injectable } from '@angular/core';
import { hasValue } from '../../shared/empty.util';
import { RegistryMetadataschemasSuccessResponse, RestResponse } from '../cache/response.models';
import { DSpaceRESTV2Response } from '../dspace-rest-v2/dspace-rest-v2-response.model';
import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer';
import { RegistryMetadataschemasResponse } from '../registry/registry-metadataschemas-response.model';
import { DSOResponseParsingService } from './dso-response-parsing.service';
import { ResponseParsingService } from './parsing.service';
import { RestRequest } from './request.models';
@Injectable()
export class RegistryMetadataschemasResponseParsingService implements ResponseParsingService {
constructor(private dsoParser: DSOResponseParsingService) {
}
parse(request: RestRequest, data: DSpaceRESTV2Response): RestResponse {
const payload = data.payload;
let metadataschemas = [];
if (hasValue(payload._embedded)) {
metadataschemas = payload._embedded.metadataschemas;
}
payload.metadataschemas = metadataschemas;
const deserialized = new DSpaceSerializer(RegistryMetadataschemasResponse).deserialize(payload);
return new RegistryMetadataschemasSuccessResponse(deserialized, data.statusCode, data.statusText, this.dsoParser.processPageInfo(data.payload));
}
}

View File

@@ -21,7 +21,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s
import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { dataService } from '../cache/builders/build-decorators';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { SearchParam } from '../cache/models/search-param.model';
import { RequestParam } from '../cache/models/request-param.model';
import { ObjectCacheService } from '../cache/object-cache.service';
import { RestResponse } from '../cache/response.models';
import { CoreState } from '../core.reducers';
@@ -257,7 +257,7 @@ export class RelationshipService extends DataService<Relationship> {
if (options) {
findListOptions = Object.assign(new FindListOptions(), options);
}
const searchParams = [new SearchParam('label', label), new SearchParam('dso', item.id)];
const searchParams = [new RequestParam('label', label), new RequestParam('dso', item.id)];
if (findListOptions.searchParams) {
findListOptions.searchParams = [...findListOptions.searchParams, ...searchParams];
} else {

View File

@@ -11,11 +11,9 @@ import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { SubmissionResponseParsingService } from '../submission/submission-response-parsing.service';
import { IntegrationResponseParsingService } from '../integration/integration-response-parsing.service';
import { RestRequestMethod } from './rest-request-method';
import { SearchParam } from '../cache/models/search-param.model';
import { RequestParam } from '../cache/models/request-param.model';
import { EpersonResponseParsingService } from '../eperson/eperson-response-parsing.service';
import { BrowseItemsResponseParsingService } from './browse-items-response-parsing-service';
import { MetadataschemaParsingService } from './metadataschema-parsing.service';
import { MetadatafieldParsingService } from './metadatafield-parsing.service';
import { URLCombiner } from '../url-combiner/url-combiner';
import { TaskResponseParsingService } from '../tasks/task-response-parsing.service';
import { ContentSourceResponseParsingService } from './content-source-response-parsing.service';
@@ -146,7 +144,7 @@ export class FindListOptions {
elementsPerPage?: number;
currentPage?: number;
sort?: SortOptions;
searchParams?: SearchParam[];
searchParams?: RequestParam[];
startsWith?: string;
}
@@ -251,58 +249,6 @@ export class IntegrationRequest extends GetRequest {
}
}
/**
* Request to create a MetadataSchema
*/
export class CreateMetadataSchemaRequest extends PostRequest {
constructor(uuid: string, href: string, public body?: any, public options?: HttpOptions) {
super(uuid, href, body, options);
}
getResponseParser(): GenericConstructor<ResponseParsingService> {
return MetadataschemaParsingService;
}
}
/**
* Request to update a MetadataSchema
*/
export class UpdateMetadataSchemaRequest extends PutRequest {
constructor(uuid: string, href: string, public body?: any, public options?: HttpOptions) {
super(uuid, href, body, options);
}
getResponseParser(): GenericConstructor<ResponseParsingService> {
return MetadataschemaParsingService;
}
}
/**
* Request to create a MetadataField
*/
export class CreateMetadataFieldRequest extends PostRequest {
constructor(uuid: string, href: string, public body?: any, public options?: HttpOptions) {
super(uuid, href, body, options);
}
getResponseParser(): GenericConstructor<ResponseParsingService> {
return MetadatafieldParsingService;
}
}
/**
* Request to update a MetadataField
*/
export class UpdateMetadataFieldRequest extends PutRequest {
constructor(uuid: string, href: string, public body?: any, public options?: HttpOptions) {
super(uuid, href, body, options);
}
getResponseParser(): GenericConstructor<ResponseParsingService> {
return MetadatafieldParsingService;
}
}
/**
* Class representing a submission HTTP GET request object
*/

View File

@@ -201,8 +201,9 @@ export class RequestService {
* Remove all request cache providing (part of) the href
* This also includes href-to-uuid index cache
* @param href A substring of the request(s) href
* @return Returns an observable emitting whether or not the cache is removed
*/
removeByHrefSubstring(href: string) {
removeByHrefSubstring(href: string): Observable<boolean> {
this.store.pipe(
select(uuidsFromHrefSubstringSelector(requestIndexSelector, href)),
take(1)
@@ -213,6 +214,11 @@ export class RequestService {
});
this.requestsOnTheirWayToTheStore = this.requestsOnTheirWayToTheStore.filter((reqHref: string) => reqHref.indexOf(href) < 0);
this.indexStore.dispatch(new RemoveFromIndexBySubstringAction(IndexName.REQUEST, href));
return this.store.pipe(
select(uuidsFromHrefSubstringSelector(requestIndexSelector, href)),
map((uuids) => isEmpty(uuids))
);
}
/**

View File

@@ -1,75 +0,0 @@
import { HttpClient } from '@angular/common/http';
import { cold, getTestScheduler } from 'jasmine-marbles';
import { TestScheduler } from 'rxjs/testing';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { ResourcePolicy } from '../shared/resource-policy.model';
import { RequestService } from './request.service';
import { ResourcePolicyService } from './resource-policy.service';
describe('ResourcePolicyService', () => {
let scheduler: TestScheduler;
let service: ResourcePolicyService;
let requestService: RequestService;
let rdbService: RemoteDataBuildService;
let objectCache: ObjectCacheService;
const testObject = {
uuid: '664184ee-b254-45e8-970d-220e5ccc060b'
} as ResourcePolicy;
const requestURL = `https://rest.api/rest/api/resourcepolicies/${testObject.uuid}`;
const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a';
beforeEach(() => {
scheduler = getTestScheduler();
requestService = jasmine.createSpyObj('requestService', {
generateRequestId: requestUUID,
configure: true
});
rdbService = jasmine.createSpyObj('rdbService', {
buildSingle: cold('a', {
a: {
payload: testObject
}
})
});
objectCache = {} as ObjectCacheService;
const halService = {} as HALEndpointService;
const notificationsService = {} as NotificationsService;
const http = {} as HttpClient;
const comparator = {} as any;
service = new ResourcePolicyService(
requestService,
rdbService,
objectCache,
halService,
notificationsService,
http,
comparator
);
spyOn((service as any).dataService, 'findByHref').and.callThrough();
});
describe('findByHref', () => {
it('should proxy the call to dataservice.findByHref', () => {
scheduler.schedule(() => service.findByHref(requestURL));
scheduler.flush();
expect((service as any).dataService.findByHref).toHaveBeenCalledWith(requestURL);
});
it('should return a RemoteData<ResourcePolicy> for the object with the given URL', () => {
const result = service.findByHref(requestURL);
const expected = cold('a', {
a: {
payload: testObject
}
});
expect(result).toBeObservable(expected);
});
});
});

View File

@@ -1,95 +0,0 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { dataService } from '../cache/builders/build-decorators';
import { DataService } from '../data/data.service';
import { RequestService } from '../data/request.service';
import { FindListOptions } from '../data/request.models';
import { Collection } from '../shared/collection.model';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { ResourcePolicy } from '../shared/resource-policy.model';
import { RemoteData } from '../data/remote-data';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { CoreState } from '../core.reducers';
import { ObjectCacheService } from '../cache/object-cache.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { RESOURCE_POLICY } from '../shared/resource-policy.resource-type';
import { ChangeAnalyzer } from './change-analyzer';
import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service';
import { PaginatedList } from './paginated-list';
/* tslint:disable:max-classes-per-file */
/**
* A private DataService implementation to delegate specific methods to.
*/
class DataServiceImpl extends DataService<ResourcePolicy> {
protected linkPath = 'resourcepolicies';
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: ChangeAnalyzer<ResourcePolicy>) {
super();
}
}
/**
* A service responsible for fetching/sending data from/to the REST API on the resourcepolicies endpoint
*/
@Injectable()
@dataService(RESOURCE_POLICY)
export class ResourcePolicyService {
private dataService: DataServiceImpl;
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DefaultChangeAnalyzer<ResourcePolicy>) {
this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator);
}
/**
* Returns an observable of {@link RemoteData} of a {@link ResourcePolicy}, based on an href, with a list of {@link FollowLinkConfig},
* to automatically resolve {@link HALLink}s of the {@link ResourcePolicy}
* @param href The url of {@link ResourcePolicy} we want to retrieve
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
findByHref(href: string, ...linksToFollow: Array<FollowLinkConfig<ResourcePolicy>>): Observable<RemoteData<ResourcePolicy>> {
return this.dataService.findByHref(href, ...linksToFollow);
}
/**
* Returns a list of observables of {@link RemoteData} of {@link ResourcePolicy}s, based on an href, with a list of {@link FollowLinkConfig},
* to automatically resolve {@link HALLink}s of the {@link ResourcePolicy}
* @param href The url of the {@link ResourcePolicy} we want to retrieve
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
findAllByHref(href: string, findListOptions: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<ResourcePolicy>>): Observable<RemoteData<PaginatedList<ResourcePolicy>>> {
return this.dataService.findAllByHref(href, findListOptions, ...linksToFollow);
}
/**
* Return the defaultAccessConditions {@link ResourcePolicy} list for a given {@link Collection}
*
* @param collection the {@link Collection} to retrieve the defaultAccessConditions for
* @param findListOptions the {@link FindListOptions} for the request
*/
getDefaultAccessConditionsFor(collection: Collection, findListOptions?: FindListOptions): Observable<RemoteData<PaginatedList<ResourcePolicy>>> {
return this.dataService.findAllByHref(collection._links.defaultAccessConditions.href, findListOptions);
}
}

View File

@@ -9,7 +9,7 @@ import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { Observable } from 'rxjs/internal/Observable';
import { TestScheduler } from 'rxjs/testing';
import { EPeopleRegistryCancelEPersonAction, EPeopleRegistryEditEPersonAction } from '../../+admin/admin-access-control/epeople-registry/epeople-registry.actions';
import { SearchParam } from '../cache/models/search-param.model';
import { RequestParam } from '../cache/models/request-param.model';
import { CoreState } from '../core.reducers';
import { ChangeAnalyzer } from '../data/change-analyzer';
import { PaginatedList } from '../data/paginated-list';
@@ -105,7 +105,7 @@ describe('EPersonDataService', () => {
it('search by default scope (byMetadata) and no query', () => {
service.searchByScope(null, '');
const options = Object.assign(new FindListOptions(), {
searchParams: [Object.assign(new SearchParam('query', ''))]
searchParams: [Object.assign(new RequestParam('query', ''))]
});
expect(service.searchBy).toHaveBeenCalledWith('byMetadata', options);
});
@@ -113,7 +113,7 @@ describe('EPersonDataService', () => {
it('search metadata scope and no query', () => {
service.searchByScope('metadata', '');
const options = Object.assign(new FindListOptions(), {
searchParams: [Object.assign(new SearchParam('query', ''))]
searchParams: [Object.assign(new RequestParam('query', ''))]
});
expect(service.searchBy).toHaveBeenCalledWith('byMetadata', options);
});
@@ -121,7 +121,7 @@ describe('EPersonDataService', () => {
it('search metadata scope and with query', () => {
service.searchByScope('metadata', 'test');
const options = Object.assign(new FindListOptions(), {
searchParams: [Object.assign(new SearchParam('query', 'test'))]
searchParams: [Object.assign(new RequestParam('query', 'test'))]
});
expect(service.searchBy).toHaveBeenCalledWith('byMetadata', options);
});
@@ -129,7 +129,7 @@ describe('EPersonDataService', () => {
it('search email scope and no query', () => {
service.searchByScope('email', '');
const options = Object.assign(new FindListOptions(), {
searchParams: [Object.assign(new SearchParam('email', ''))]
searchParams: [Object.assign(new RequestParam('email', ''))]
});
expect(service.searchBy).toHaveBeenCalledWith('byEmail', options);
});

View File

@@ -15,7 +15,7 @@ import { NotificationsService } from '../../shared/notifications/notifications.s
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { dataService } from '../cache/builders/build-decorators';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { SearchParam } from '../cache/models/search-param.model';
import { RequestParam } from '../cache/models/request-param.model';
import { ObjectCacheService } from '../cache/object-cache.service';
import { RestResponse } from '../cache/response.models';
import { DataService } from '../data/data.service';
@@ -97,7 +97,7 @@ export class EPersonDataService extends DataService<EPerson> {
* @param linksToFollow
*/
private getEpeopleByEmail(query: string, options?: FindListOptions, ...linksToFollow: Array<FollowLinkConfig<EPerson>>): Observable<RemoteData<PaginatedList<EPerson>>> {
const searchParams = [new SearchParam('email', query)];
const searchParams = [new RequestParam('email', query)];
return this.getEPeopleBy(searchParams, this.searchByEmailPath, options, ...linksToFollow);
}
@@ -108,7 +108,7 @@ export class EPersonDataService extends DataService<EPerson> {
* @param linksToFollow
*/
private getEpeopleByMetadata(query: string, options?: FindListOptions, ...linksToFollow: Array<FollowLinkConfig<EPerson>>): Observable<RemoteData<PaginatedList<EPerson>>> {
const searchParams = [new SearchParam('query', query)];
const searchParams = [new RequestParam('query', query)];
return this.getEPeopleBy(searchParams, this.searchByMetadataPath, options, ...linksToFollow);
}
@@ -119,7 +119,7 @@ export class EPersonDataService extends DataService<EPerson> {
* @param options
* @param linksToFollow
*/
private getEPeopleBy(searchParams: SearchParam[], searchMethod: string, options?: FindListOptions, ...linksToFollow: Array<FollowLinkConfig<EPerson>>): Observable<RemoteData<PaginatedList<EPerson>>> {
private getEPeopleBy(searchParams: RequestParam[], searchMethod: string, options?: FindListOptions, ...linksToFollow: Array<FollowLinkConfig<EPerson>>): Observable<RemoteData<PaginatedList<EPerson>>> {
let findListOptions = new FindListOptions();
if (options) {
findListOptions = Object.assign(new FindListOptions(), options);

View File

@@ -11,7 +11,7 @@ import {
GroupRegistryEditGroupAction
} from '../../+admin/admin-access-control/group-registry/group-registry.actions';
import { GroupMock, GroupMock2 } from '../../shared/testing/group-mock';
import { SearchParam } from '../cache/models/search-param.model';
import { RequestParam } from '../cache/models/request-param.model';
import { CoreState } from '../core.reducers';
import { ChangeAnalyzer } from '../data/change-analyzer';
import { PaginatedList } from '../data/paginated-list';
@@ -103,7 +103,7 @@ describe('GroupDataService', () => {
it('search with empty query', () => {
service.searchGroups('');
const options = Object.assign(new FindListOptions(), {
searchParams: [Object.assign(new SearchParam('query', ''))]
searchParams: [Object.assign(new RequestParam('query', ''))]
});
expect(service.searchBy).toHaveBeenCalledWith('byMetadata', options);
});
@@ -111,7 +111,7 @@ describe('GroupDataService', () => {
it('search with query', () => {
service.searchGroups('test');
const options = Object.assign(new FindListOptions(), {
searchParams: [Object.assign(new SearchParam('query', 'test'))]
searchParams: [Object.assign(new RequestParam('query', 'test'))]
});
expect(service.searchBy).toHaveBeenCalledWith('byMetadata', options);
});

View File

@@ -14,7 +14,7 @@ import { hasValue } from '../../shared/empty.util';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { SearchParam } from '../cache/models/search-param.model';
import { RequestParam } from '../cache/models/request-param.model';
import { ObjectCacheService } from '../cache/object-cache.service';
import { RestResponse } from '../cache/response.models';
import { DataService } from '../data/data.service';
@@ -97,7 +97,7 @@ export class GroupDataService extends DataService<Group> {
* @param linksToFollow
*/
public searchGroups(query: string, options?: FindListOptions, ...linksToFollow: Array<FollowLinkConfig<Group>>): Observable<RemoteData<PaginatedList<Group>>> {
const searchParams = [new SearchParam('query', query)];
const searchParams = [new RequestParam('query', query)];
let findListOptions = new FindListOptions();
if (options) {
findListOptions = Object.assign(new FindListOptions(), options);
@@ -121,7 +121,7 @@ export class GroupDataService extends DataService<Group> {
isMemberOf(groupName: string): Observable<boolean> {
const searchHref = 'isMemberOf';
const options = new FindListOptions();
options.searchParams = [new SearchParam('groupName', groupName)];
options.searchParams = [new RequestParam('groupName', groupName)];
return this.searchBy(searchHref, options).pipe(
filter((groups: RemoteData<PaginatedList<Group>>) => !groups.isResponsePending),

View File

@@ -1,24 +0,0 @@
import { autoserialize, deserialize } from 'cerialize';
import { BITSTREAM_FORMAT } from '../shared/bitstream-format.resource-type';
import { HALLink } from '../shared/hal-link.model';
import { PageInfo } from '../shared/page-info.model';
import { BitstreamFormat } from '../shared/bitstream-format.model';
import { link } from '../cache/builders/build-decorators';
export class RegistryBitstreamformatsResponse {
@autoserialize
page: PageInfo;
/**
* The {@link HALLink}s for this RegistryBitstreamformatsResponse
*/
@deserialize
_links: {
self: HALLink;
bitstreamformats: HALLink;
};
@link(BITSTREAM_FORMAT)
bitstreamformats?: BitstreamFormat[];
}

View File

@@ -1,46 +0,0 @@
import { autoserialize, deserialize } from 'cerialize';
import { typedObject } from '../cache/builders/build-decorators';
import { MetadataField } from '../metadata/metadata-field.model';
import { METADATA_FIELD } from '../metadata/metadata-field.resource-type';
import { HALLink } from '../shared/hal-link.model';
import { PageInfo } from '../shared/page-info.model';
import { ResourceType } from '../shared/resource-type';
import { excludeFromEquals } from '../utilities/equals.decorators';
/**
* Class that represents a response with a registry's metadata fields
*/
@typedObject
export class RegistryMetadatafieldsResponse {
static type = METADATA_FIELD;
/**
* The object type
*/
@excludeFromEquals
@autoserialize
type: ResourceType;
/**
* List of metadata fields in the response
*/
@deserialize
metadatafields: MetadataField[];
/**
* Page info of this response
*/
@autoserialize
page: PageInfo;
/**
* The REST link to this response
*/
@autoserialize
self: string;
@deserialize
_links: {
self: HALLink,
}
}

View File

@@ -1,14 +0,0 @@
import { PageInfo } from '../shared/page-info.model';
import { autoserialize, deserialize } from 'cerialize';
import { MetadataSchema } from '../metadata/metadata-schema.model';
export class RegistryMetadataschemasResponse {
@deserialize
metadataschemas: MetadataSchema[];
@autoserialize
page: PageInfo;
@autoserialize
self: string;
}

View File

@@ -3,8 +3,7 @@ import { Component } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Store, StoreModule } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { combineLatest as observableCombineLatest, Observable, of as observableOf } from 'rxjs';
import { map } from 'rxjs/operators';
import { Observable, of as observableOf } from 'rxjs';
import {
MetadataRegistryCancelFieldAction,
MetadataRegistryCancelSchemaAction,
@@ -17,30 +16,20 @@ import {
MetadataRegistrySelectFieldAction,
MetadataRegistrySelectSchemaAction
} from '../../+admin/admin-registries/metadata-registry/metadata-registry.actions';
import { getMockRequestService } from '../../shared/mocks/request.service.mock';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { StoreMock } from '../../shared/testing/store.mock';
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import {
RegistryMetadatafieldsSuccessResponse,
RegistryMetadataschemasSuccessResponse,
RestResponse
} from '../cache/response.models';
import { RemoteData } from '../data/remote-data';
import { RequestEntry } from '../data/request.reducer';
import { RequestService } from '../data/request.service';
import { RestResponse } from '../cache/response.models';
import { MetadataField } from '../metadata/metadata-field.model';
import { MetadataSchema } from '../metadata/metadata-schema.model';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { PageInfo } from '../shared/page-info.model';
import { RegistryMetadatafieldsResponse } from './registry-metadatafields-response.model';
import { RegistryMetadataschemasResponse } from './registry-metadataschemas-response.model';
import { RegistryService } from './registry.service';
import { storeModuleConfig } from '../../app.reducer';
import { FindListOptions } from '../data/request.models';
import { MetadataSchemaDataService } from '../data/metadata-schema-data.service';
import { MetadataFieldDataService } from '../data/metadata-field-data.service';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { createPaginatedList } from '../../shared/testing/utils.test';
@Component({ template: '' })
class DummyComponent {
@@ -49,211 +38,169 @@ class DummyComponent {
describe('RegistryService', () => {
let registryService: RegistryService;
let mockStore;
const pagination: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
id: 'registry-service-spec-pagination',
pageSize: 20
});
let metadataSchemaService: MetadataSchemaDataService;
let metadataFieldService: MetadataFieldDataService;
const mockSchemasList = [
Object.assign(new MetadataSchema(), {
id: 1,
_links: {
self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadataschemas/1' }
},
prefix: 'dc',
namespace: 'http://dublincore.org/documents/dcmi-terms/',
type: MetadataSchema.type
}),
Object.assign(new MetadataSchema(), {
id: 2,
_links: {
self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadataschemas/2' }
},
prefix: 'mock',
namespace: 'http://dspace.org/mockschema',
type: MetadataSchema.type
})
];
const mockFieldsList = [
Object.assign(new MetadataField(),
{
let options: FindListOptions;
let mockSchemasList: MetadataSchema[];
let mockFieldsList: MetadataField[];
function init() {
options = Object.assign(new FindListOptions(), {
currentPage: 1,
elementsPerPage: 20
});
mockSchemasList = [
Object.assign(new MetadataSchema(), {
id: 1,
_links: {
self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/8' }
self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadataschemas/1' }
},
element: 'contributor',
qualifier: 'advisor',
scopeNote: null,
schema: mockSchemasList[0],
type: MetadataField.type
prefix: 'dc',
namespace: 'http://dublincore.org/documents/dcmi-terms/',
type: MetadataSchema.type
}),
Object.assign(new MetadataField(),
{
Object.assign(new MetadataSchema(), {
id: 2,
_links: {
self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/9' }
self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadataschemas/2' }
},
element: 'contributor',
qualifier: 'author',
scopeNote: null,
schema: mockSchemasList[0],
type: MetadataField.type
}),
Object.assign(new MetadataField(),
{
id: 3,
_links: {
self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/10' }
},
element: 'contributor',
qualifier: 'editor',
scopeNote: 'test scope note',
schema: mockSchemasList[1],
type: MetadataField.type
}),
Object.assign(new MetadataField(),
{
id: 4,
_links: {
self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/11' }
},
element: 'contributor',
qualifier: 'illustrator',
scopeNote: null,
schema: mockSchemasList[1],
type: MetadataField.type
prefix: 'mock',
namespace: 'http://dspace.org/mockschema',
type: MetadataSchema.type
})
];
];
const pageInfo = new PageInfo();
pageInfo.elementsPerPage = 20;
pageInfo.currentPage = 1;
const endpoint = 'path';
const endpointWithParams = `${endpoint}?size=${pageInfo.elementsPerPage}&page=${pageInfo.currentPage - 1}`;
const fieldEndpointWithParams = `${endpoint}?schema=${mockSchemasList[0].prefix}&size=${pageInfo.elementsPerPage}&page=${pageInfo.currentPage - 1}`;
const halServiceStub = {
getEndpoint: (link: string) => observableOf(endpoint)
};
const rdbStub = {
toRemoteDataObservable: (requestEntryObs: Observable<RequestEntry>, payloadObs: Observable<any>) => {
return observableCombineLatest(requestEntryObs,
payloadObs).pipe(map(([req, pay]) => {
return { req, pay };
mockFieldsList = [
Object.assign(new MetadataField(),
{
id: 1,
_links: {
self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/8' }
},
element: 'contributor',
qualifier: 'advisor',
scopeNote: null,
schema: mockSchemasList[0],
type: MetadataField.type
}),
Object.assign(new MetadataField(),
{
id: 2,
_links: {
self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/9' }
},
element: 'contributor',
qualifier: 'author',
scopeNote: null,
schema: mockSchemasList[0],
type: MetadataField.type
}),
Object.assign(new MetadataField(),
{
id: 3,
_links: {
self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/10' }
},
element: 'contributor',
qualifier: 'editor',
scopeNote: 'test scope note',
schema: mockSchemasList[1],
type: MetadataField.type
}),
Object.assign(new MetadataField(),
{
id: 4,
_links: {
self: { href: 'https://dspace7.4science.it/dspace-spring-rest/api/core/metadatafields/11' }
},
element: 'contributor',
qualifier: 'illustrator',
scopeNote: null,
schema: mockSchemasList[1],
type: MetadataField.type
})
);
},
aggregate: (input: Array<Observable<RemoteData<any>>>): Observable<RemoteData<any[]>> => {
return createSuccessfulRemoteDataObject$([]);
}
};
];
metadataSchemaService = jasmine.createSpyObj('metadataSchemaService', {
findAll: createSuccessfulRemoteDataObject$(createPaginatedList(mockSchemasList)),
findById: createSuccessfulRemoteDataObject$(mockSchemasList[0]),
createOrUpdateMetadataSchema: createSuccessfulRemoteDataObject$(mockSchemasList[0]),
deleteAndReturnResponse: observableOf(new RestResponse(true, 200, 'OK')),
clearRequests: observableOf('href')
});
metadataFieldService = jasmine.createSpyObj('metadataFieldService', {
findAll: createSuccessfulRemoteDataObject$(createPaginatedList(mockFieldsList)),
findById: createSuccessfulRemoteDataObject$(mockFieldsList[0]),
createOrUpdateMetadataField: createSuccessfulRemoteDataObject$(mockFieldsList[0]),
deleteAndReturnResponse: observableOf(new RestResponse(true, 200, 'OK')),
clearRequests: observableOf('href')
});
}
beforeEach(() => {
init();
TestBed.configureTestingModule({
imports: [CommonModule, StoreModule.forRoot({}, storeModuleConfig), TranslateModule.forRoot()],
declarations: [
DummyComponent
],
providers: [
{ provide: RequestService, useValue: getMockRequestService() },
{ provide: RemoteDataBuildService, useValue: rdbStub },
{ provide: HALEndpointService, useValue: halServiceStub },
{ provide: Store, useClass: StoreMock },
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
{ provide: MetadataSchemaDataService, useValue: metadataSchemaService },
{ provide: MetadataFieldDataService, useValue: metadataFieldService },
RegistryService
]
});
registryService = TestBed.get(RegistryService);
mockStore = TestBed.get(Store);
spyOn((registryService as any).halService, 'getEndpoint').and.returnValue(observableOf(endpoint));
});
describe('when requesting metadataschemas', () => {
const queryResponse = Object.assign(new RegistryMetadataschemasResponse(), {
metadataschemas: mockSchemasList,
page: pageInfo
});
const response = new RegistryMetadataschemasSuccessResponse(queryResponse, 200, 'OK', pageInfo);
const responseEntry = Object.assign(new RequestEntry(), { response: response });
let result;
beforeEach(() => {
(registryService as any).requestService.getByHref.and.returnValue(observableOf(responseEntry));
/* tslint:disable:no-empty */
registryService.getMetadataSchemas(pagination).subscribe((value) => {
result = registryService.getMetadataSchemas(options);
});
it('should call metadataSchemaService.findAll', (done) => {
result.subscribe(() => {
expect(metadataSchemaService.findAll).toHaveBeenCalled();
done();
});
/* tslint:enable:no-empty */
});
it('should call getEndpoint on the halService', () => {
expect((registryService as any).halService.getEndpoint).toHaveBeenCalled();
});
it('should send out the request on the request service', () => {
expect((registryService as any).requestService.configure).toHaveBeenCalled();
});
it('should call getByHref on the request service with the correct request url', () => {
expect((registryService as any).requestService.getByHref).toHaveBeenCalledWith(endpointWithParams);
});
});
describe('when requesting metadataschema by name', () => {
const queryResponse = Object.assign(new RegistryMetadataschemasResponse(), {
metadataschemas: mockSchemasList,
page: pageInfo
});
const response = new RegistryMetadataschemasSuccessResponse(queryResponse, 200, 'OK', pageInfo);
const responseEntry = Object.assign(new RequestEntry(), { response: response });
let result;
beforeEach(() => {
(registryService as any).requestService.getByHref.and.returnValue(observableOf(responseEntry));
/* tslint:disable:no-empty */
registryService.getMetadataSchemaByName(mockSchemasList[0].prefix).subscribe((value) => {
result = registryService.getMetadataSchemaByName(mockSchemasList[0].prefix);
});
it('should call metadataSchemaService.findById with the correct ID', (done) => {
result.subscribe(() => {
expect(metadataSchemaService.findById).toHaveBeenCalledWith(`${mockSchemasList[0].id}`);
done();
});
/* tslint:enable:no-empty */
});
it('should call getEndpoint on the halService', () => {
expect((registryService as any).halService.getEndpoint).toHaveBeenCalled();
});
it('should send out the request on the request service', () => {
expect((registryService as any).requestService.configure).toHaveBeenCalled();
});
it('should call getByHref on the request service with the correct request url', () => {
expect((registryService as any).requestService.getByHref.calls.argsFor(0)[0]).toContain(endpoint);
});
});
describe('when requesting metadatafields', () => {
const queryResponse = Object.assign(new RegistryMetadatafieldsResponse(), {
metadatafields: mockFieldsList,
page: pageInfo
});
const response = new RegistryMetadatafieldsSuccessResponse(queryResponse, 200, 'OK', pageInfo);
const responseEntry = Object.assign(new RequestEntry(), { response: response });
let result;
beforeEach(() => {
(registryService as any).requestService.getByHref.and.returnValue(observableOf(responseEntry));
/* tslint:disable:no-empty */
registryService.getMetadataFieldsBySchema(mockSchemasList[0], pagination).subscribe((value) => {
result = registryService.getAllMetadataFields();
});
it('should call metadataFieldService.findAll', (done) => {
result.subscribe(() => {
expect(metadataFieldService.findAll).toHaveBeenCalled();
done();
});
/* tslint:enable:no-empty */
});
it('should call getEndpoint on the halService', () => {
expect((registryService as any).halService.getEndpoint).toHaveBeenCalled();
});
it('should send out the request on the request service', () => {
expect((registryService as any).requestService.configure).toHaveBeenCalled();
});
it('should call getByHref on the request service with the correct request url', () => {
expect((registryService as any).requestService.getByHref).toHaveBeenCalledWith(fieldEndpointWithParams);
});
});
@@ -370,9 +317,10 @@ describe('RegistryService', () => {
result = registryService.createOrUpdateMetadataSchema(mockSchemasList[0]);
});
it('should return the created/updated metadata schema', () => {
it('should return the created/updated metadata schema', (done) => {
result.subscribe((schema: MetadataSchema) => {
expect(schema).toEqual(mockSchemasList[0]);
done();
});
});
});
@@ -384,9 +332,10 @@ describe('RegistryService', () => {
result = registryService.createOrUpdateMetadataField(mockFieldsList[0]);
});
it('should return the created/updated metadata field', () => {
it('should return the created/updated metadata field', (done) => {
result.subscribe((field: MetadataField) => {
expect(field).toEqual(mockFieldsList[0]);
done();
});
});
});
@@ -425,7 +374,7 @@ describe('RegistryService', () => {
});
it('should remove the requests related to metadata schemas from cache', () => {
expect((registryService as any).requestService.removeByHrefSubstring).toHaveBeenCalled();
expect(metadataSchemaService.clearRequests).toHaveBeenCalled();
});
});
@@ -435,7 +384,7 @@ describe('RegistryService', () => {
});
it('should remove the requests related to metadata fields from cache', () => {
expect((registryService as any).requestService.removeByHrefSubstring).toHaveBeenCalled();
expect(metadataFieldService.clearRequests).toHaveBeenCalled();
});
});
});

View File

@@ -2,37 +2,18 @@ import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
import { Injectable } from '@angular/core';
import { RemoteData } from '../data/remote-data';
import { PaginatedList } from '../data/paginated-list';
import { DSpaceSerializer } from '../dspace-rest-v2/dspace.serializer';
import { PageInfo } from '../shared/page-info.model';
import {
CreateMetadataFieldRequest,
CreateMetadataSchemaRequest,
DeleteRequest,
GetRequest,
RestRequest,
UpdateMetadataFieldRequest,
UpdateMetadataSchemaRequest
} from '../data/request.models';
import { GenericConstructor } from '../shared/generic-constructor';
import { ResponseParsingService } from '../data/parsing.service';
import { RegistryMetadataschemasResponseParsingService } from '../data/registry-metadataschemas-response-parsing.service';
import { FindListOptions } from '../data/request.models';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { RequestService } from '../data/request.service';
import { RegistryMetadataschemasResponse } from './registry-metadataschemas-response.model';
import {
MetadatafieldSuccessResponse,
MetadataschemaSuccessResponse,
RegistryMetadatafieldsSuccessResponse,
RegistryMetadataschemasSuccessResponse,
RestResponse
} from '../cache/response.models';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { RegistryMetadatafieldsResponseParsingService } from '../data/registry-metadatafields-response-parsing.service';
import { RegistryMetadatafieldsResponse } from './registry-metadatafields-response.model';
import { hasNoValue, hasValue, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
import { URLCombiner } from '../url-combiner/url-combiner';
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { configureRequest, getResponseFromEntry } from '../shared/operators';
import { hasNoValue, hasValue, hasValueOperator, isNotEmpty, isNotEmptyOperator } from '../../shared/empty.util';
import { getAllSucceededRemoteDataPayload, getFirstSucceededRemoteDataPayload } from '../shared/operators';
import { createSelector, select, Store } from '@ngrx/store';
import { AppState } from '../../app.reducer';
import { MetadataRegistryState } from '../../+admin/admin-registries/metadata-registry/metadata-registry.reducers';
@@ -48,15 +29,14 @@ import {
MetadataRegistrySelectFieldAction,
MetadataRegistrySelectSchemaAction
} from '../../+admin/admin-registries/metadata-registry/metadata-registry.actions';
import { distinctUntilChanged, flatMap, map, take, tap } from 'rxjs/operators';
import { flatMap, map, tap } from 'rxjs/operators';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { NotificationOptions } from '../../shared/notifications/models/notification-options.model';
import { HttpOptions } from '../dspace-rest-v2/dspace-rest-v2.service';
import { HttpHeaders } from '@angular/common/http';
import { TranslateService } from '@ngx-translate/core';
import { MetadataSchema } from '../metadata/metadata-schema.model';
import { MetadataField } from '../metadata/metadata-field.model';
import { getClassForType } from '../cache/builders/build-decorators';
import { MetadataSchemaDataService } from '../data/metadata-schema-data.service';
import { MetadataFieldDataService } from '../data/metadata-field-data.service';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
const metadataRegistryStateSelector = (state: AppState) => state.metadataRegistry;
const editMetadataSchemaSelector = createSelector(metadataRegistryStateSelector, (metadataState: MetadataRegistryState) => metadataState.editSchema);
@@ -70,221 +50,64 @@ const selectedMetadataFieldsSelector = createSelector(metadataRegistryStateSelec
@Injectable()
export class RegistryService {
private metadataSchemasPath = 'metadataschemas';
private metadataFieldsPath = 'metadatafields';
// private bitstreamFormatsPath = 'bitstreamformats';
constructor(protected requestService: RequestService,
private rdb: RemoteDataBuildService,
private halService: HALEndpointService,
private store: Store<AppState>,
constructor(private store: Store<AppState>,
private notificationsService: NotificationsService,
private translateService: TranslateService) {
private translateService: TranslateService,
private metadataSchemaService: MetadataSchemaDataService,
private metadataFieldService: MetadataFieldDataService) {
}
/**
* Retrieves all metadata schemas
* @param pagination The pagination info used to retrieve the schemas
* @param options The options used to retrieve the schemas
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
public getMetadataSchemas(pagination: PaginationComponentOptions): Observable<RemoteData<PaginatedList<MetadataSchema>>> {
const requestObs = this.getMetadataSchemasRequestObs(pagination);
const requestEntryObs = requestObs.pipe(
flatMap((request: RestRequest) => this.requestService.getByHref(request.href))
);
const rmrObs: Observable<RegistryMetadataschemasResponse> = requestEntryObs.pipe(
getResponseFromEntry(),
map((response: RegistryMetadataschemasSuccessResponse) => response.metadataschemasResponse)
);
const metadataschemasObs: Observable<MetadataSchema[]> = rmrObs.pipe(
map((rmr: RegistryMetadataschemasResponse) => rmr.metadataschemas)
);
const pageInfoObs: Observable<PageInfo> = requestEntryObs.pipe(
getResponseFromEntry(),
map((response: RegistryMetadataschemasSuccessResponse) => response.pageInfo)
);
const payloadObs = observableCombineLatest(metadataschemasObs, pageInfoObs).pipe(
map(([metadataschemas, pageInfo]) => {
return new PaginatedList(pageInfo, metadataschemas);
})
);
return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs);
public getMetadataSchemas(options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<MetadataSchema>>): Observable<RemoteData<PaginatedList<MetadataSchema>>> {
return this.metadataSchemaService.findAll(options, ...linksToFollow);
}
/**
* Retrieves a metadata schema by its name
* @param schemaName The name of the schema to find
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
public getMetadataSchemaByName(schemaName: string): Observable<RemoteData<MetadataSchema>> {
// Temporary pagination to get ALL metadataschemas until there's a rest api endpoint for fetching a specific schema
const pagination: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), {
id: 'all-metadatafields-pagination',
pageSize: 10000
public getMetadataSchemaByName(schemaName: string, ...linksToFollow: Array<FollowLinkConfig<MetadataSchema>>): Observable<RemoteData<MetadataSchema>> {
// Temporary options to get ALL metadataschemas until there's a rest api endpoint for fetching a specific schema
const options: FindListOptions = Object.assign(new FindListOptions(), {
elementsPerPage: 10000
});
const requestObs = this.getMetadataSchemasRequestObs(pagination);
const requestEntryObs = requestObs.pipe(
flatMap((request: RestRequest) => this.requestService.getByHref(request.href))
return this.getMetadataSchemas(options).pipe(
getFirstSucceededRemoteDataPayload(),
map((schemas: PaginatedList<MetadataSchema>) => schemas.page),
isNotEmptyOperator(),
map((schemas: MetadataSchema[]) => schemas.filter((schema) => schema.prefix === schemaName)[0]),
flatMap((schema: MetadataSchema) => this.metadataSchemaService.findById(`${schema.id}`, ...linksToFollow))
);
const rmrObs: Observable<RegistryMetadataschemasResponse> = requestEntryObs.pipe(
getResponseFromEntry(),
map((response: RegistryMetadataschemasSuccessResponse) => response.metadataschemasResponse)
);
const metadataschemaObs: Observable<MetadataSchema> = rmrObs.pipe(
map((rmr: RegistryMetadataschemasResponse) => rmr.metadataschemas),
map((metadataSchemas: MetadataSchema[]) => metadataSchemas.filter((value) => value.prefix === schemaName)[0])
);
return this.rdb.toRemoteDataObservable(requestEntryObs, metadataschemaObs);
}
/**
* retrieves all metadata fields that belong to a certain metadata schema
* @param schema The schema to filter by
* @param pagination The pagination info used to retrieve the fields
* @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
*/
public getMetadataFieldsBySchema(schema: MetadataSchema, pagination: PaginationComponentOptions): Observable<RemoteData<PaginatedList<MetadataField>>> {
const requestObs = this.getMetadataFieldsBySchemaRequestObs(pagination, schema);
const requestEntryObs = requestObs.pipe(
flatMap((request: RestRequest) => this.requestService.getByHref(request.href))
);
const rmrObs: Observable<RegistryMetadatafieldsResponse> = requestEntryObs.pipe(
getResponseFromEntry(),
map((response: RegistryMetadatafieldsSuccessResponse) => response.metadatafieldsResponse)
);
const metadatafieldsObs: Observable<MetadataField[]> = rmrObs.pipe(
map((rmr: RegistryMetadatafieldsResponse) => rmr.metadatafields)
);
const pageInfoObs: Observable<PageInfo> = requestEntryObs.pipe(
getResponseFromEntry(),
map((response: RegistryMetadatafieldsSuccessResponse) => response.pageInfo)
);
const payloadObs = observableCombineLatest(metadatafieldsObs, pageInfoObs).pipe(
map(([metadatafields, pageInfo]) => {
return new PaginatedList(pageInfo, metadatafields);
})
);
return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs);
public getMetadataFieldsBySchema(schema: MetadataSchema, options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<MetadataField>>): Observable<RemoteData<PaginatedList<MetadataField>>> {
return this.metadataFieldService.findBySchema(schema, options, ...linksToFollow);
}
/**
* Retrieve all existing metadata fields as a paginated list
* @param pagination Pagination options to determine which page of metadata fields should be requested
* When no pagination is provided, all metadata fields are requested in one large page
* @param options Options to determine which page of metadata fields should be requested
* When no options are provided, all metadata fields are requested in one large page
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
* @returns an observable that emits a remote data object with a page of metadata fields
*/
public getAllMetadataFields(pagination?: PaginationComponentOptions): Observable<RemoteData<PaginatedList<MetadataField>>> {
if (hasNoValue(pagination)) {
pagination = {currentPage: 1, pageSize: 10000} as any;
public getAllMetadataFields(options?: FindListOptions, ...linksToFollow: Array<FollowLinkConfig<MetadataField>>): Observable<RemoteData<PaginatedList<MetadataField>>> {
if (hasNoValue(options)) {
options = {currentPage: 1, elementsPerPage: 10000} as any;
}
const requestObs = this.getMetadataFieldsRequestObs(pagination);
const requestEntryObs = requestObs.pipe(
flatMap((request: RestRequest) => this.requestService.getByHref(request.href))
);
const rmrObs: Observable<RegistryMetadatafieldsResponse> = requestEntryObs.pipe(
getResponseFromEntry(),
map((response: RegistryMetadatafieldsSuccessResponse) => response.metadatafieldsResponse)
);
const metadatafieldsObs: Observable<MetadataField[]> = rmrObs.pipe(
map((rmr: RegistryMetadatafieldsResponse) => rmr.metadatafields),
/* Make sure to explicitly cast this into a MetadataField object, on first page loads this object comes from the object cache created by the server and its prototype is unknown */
map((metadataFields: MetadataField[]) => metadataFields.map((metadataField: MetadataField) => Object.assign(new MetadataField(), metadataField)))
);
const pageInfoObs: Observable<PageInfo> = requestEntryObs.pipe(
getResponseFromEntry(),
map((response: RegistryMetadatafieldsSuccessResponse) => response.pageInfo)
);
const payloadObs = observableCombineLatest(metadatafieldsObs, pageInfoObs).pipe(
map(([metadatafields, pageInfo]) => {
return new PaginatedList(pageInfo, metadatafields);
})
);
return this.rdb.toRemoteDataObservable(requestEntryObs, payloadObs);
}
public getMetadataSchemasRequestObs(pagination: PaginationComponentOptions): Observable<RestRequest> {
return this.halService.getEndpoint(this.metadataSchemasPath).pipe(
map((url: string) => {
const args: string[] = [];
args.push(`size=${pagination.pageSize}`);
args.push(`page=${pagination.currentPage - 1}`);
if (isNotEmpty(args)) {
url = new URLCombiner(url, `?${args.join('&')}`).toString();
}
const request = new GetRequest(this.requestService.generateRequestId(), url);
return Object.assign(request, {
getResponseParser(): GenericConstructor<ResponseParsingService> {
return RegistryMetadataschemasResponseParsingService;
}
});
}),
tap((request: RestRequest) => this.requestService.configure(request)),
);
}
private getMetadataFieldsBySchemaRequestObs(pagination: PaginationComponentOptions, schema: MetadataSchema): Observable<RestRequest> {
return this.halService.getEndpoint(this.metadataFieldsPath + '/search/bySchema').pipe(
// return this.halService.getEndpoint(this.metadataFieldsPath).pipe(
map((url: string) => {
const args: string[] = [];
args.push(`schema=${schema.prefix}`);
args.push(`size=${pagination.pageSize}`);
args.push(`page=${pagination.currentPage - 1}`);
if (isNotEmpty(args)) {
url = new URLCombiner(url, `?${args.join('&')}`).toString();
}
const request = new GetRequest(this.requestService.generateRequestId(), url);
return Object.assign(request, {
getResponseParser(): GenericConstructor<ResponseParsingService> {
return RegistryMetadatafieldsResponseParsingService;
}
});
}),
tap((request: RestRequest) => this.requestService.configure(request)),
);
}
private getMetadataFieldsRequestObs(pagination: PaginationComponentOptions): Observable<RestRequest> {
return this.halService.getEndpoint(this.metadataFieldsPath).pipe(
map((url: string) => {
const args: string[] = [];
args.push(`size=${pagination.pageSize}`);
args.push(`page=${pagination.currentPage - 1}`);
if (isNotEmpty(args)) {
url = new URLCombiner(url, `?${args.join('&')}`).toString();
}
const request = new GetRequest(this.requestService.generateRequestId(), url);
return Object.assign(request, {
getResponseParser(): GenericConstructor<ResponseParsingService> {
return RegistryMetadatafieldsResponseParsingService;
}
});
}),
tap((request: RestRequest) => this.requestService.configure(request)),
);
return this.metadataFieldService.findAll(options, ...linksToFollow);
}
public editMetadataSchema(schema: MetadataSchema) {
@@ -386,59 +209,17 @@ export class RegistryService {
* 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 CreateMetadataSchemaRequest is used
* - On update, a UpdateMetadataSchemaRequest is used
* - On creation, a CreateRequest is used
* - On update, a PutRequest is used
* @param schema The MetadataSchema to create or update
*/
public createOrUpdateMetadataSchema(schema: MetadataSchema): Observable<MetadataSchema> {
const isUpdate = hasValue(schema.id);
const requestId = this.requestService.generateRequestId();
const endpoint$ = this.halService.getEndpoint(this.metadataSchemasPath).pipe(
isNotEmptyOperator(),
map((endpoint: string) => (isUpdate ? `${endpoint}/${schema.id}` : endpoint)),
distinctUntilChanged()
);
const serializedSchema = new DSpaceSerializer(getClassForType(MetadataSchema.type)).serialize(schema);
const request$ = endpoint$.pipe(
take(1),
map((endpoint: string) => {
if (isUpdate) {
const options: HttpOptions = Object.create({});
let headers = new HttpHeaders();
headers = headers.append('Content-Type', 'application/json');
options.headers = headers;
return new UpdateMetadataSchemaRequest(requestId, endpoint, JSON.stringify(serializedSchema), options);
} else {
return new CreateMetadataSchemaRequest(requestId, endpoint, JSON.stringify(serializedSchema));
}
})
);
// Execute the post/put request
request$.pipe(
configureRequest(this.requestService)
).subscribe();
// Return created/updated schema
return this.requestService.getByUUID(requestId).pipe(
getResponseFromEntry(),
map((response: RestResponse) => {
if (!response.isSuccessful) {
if (hasValue((response as any).errorMessage)) {
this.notificationsService.error('Server Error:', (response as any).errorMessage, new NotificationOptions(-1));
}
} else {
this.showNotifications(true, isUpdate, false, {prefix: schema.prefix});
return response;
}
}),
isNotEmptyOperator(),
map((response: MetadataschemaSuccessResponse) => {
if (isNotEmpty(response.metadataschema)) {
return response.metadataschema;
}
return this.metadataSchemaService.createOrUpdateMetadataSchema(schema).pipe(
getFirstSucceededRemoteDataPayload(),
hasValueOperator(),
tap(() => {
this.showNotifications(true, isUpdate, false, {prefix: schema.prefix});
})
);
}
@@ -448,74 +229,32 @@ export class RegistryService {
* @param id The id of the metadata schema to delete
*/
public deleteMetadataSchema(id: number): Observable<RestResponse> {
return this.delete(this.metadataSchemasPath, id);
return this.metadataSchemaService.deleteAndReturnResponse(`${id}`);
}
/**
* Method that clears a cached metadata schema request and returns its REST url
*/
public clearMetadataSchemaRequests(): Observable<string> {
return this.halService.getEndpoint(this.metadataSchemasPath).pipe(
tap((href: string) => this.requestService.removeByHrefSubstring(href))
);
return this.metadataSchemaService.clearRequests();
}
/**
* 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 CreateMetadataFieldRequest is used
* - On update, a UpdateMetadataFieldRequest is used
* - On creation, a CreateRequest is used
* - On update, a PutRequest is used
* @param field The MetadataField to create or update
*/
public createOrUpdateMetadataField(field: MetadataField): Observable<MetadataField> {
const isUpdate = hasValue(field.id);
const requestId = this.requestService.generateRequestId();
const endpoint$ = this.halService.getEndpoint(this.metadataFieldsPath).pipe(
isNotEmptyOperator(),
map((endpoint: string) => (isUpdate ? `${endpoint}/${field.id}` : `${endpoint}?schemaId=${field.schema.id}`)),
distinctUntilChanged()
);
const request$ = endpoint$.pipe(
take(1),
map((endpoint: string) => {
if (isUpdate) {
const options: HttpOptions = Object.create({});
let headers = new HttpHeaders();
headers = headers.append('Content-Type', 'application/json');
options.headers = headers;
return new UpdateMetadataFieldRequest(requestId, endpoint, JSON.stringify(field), options);
} else {
return new CreateMetadataFieldRequest(requestId, endpoint, JSON.stringify(field));
}
})
);
// Execute the post/put request
request$.pipe(
configureRequest(this.requestService)
).subscribe();
// Return created/updated field
return this.requestService.getByUUID(requestId).pipe(
getResponseFromEntry(),
map((response: RestResponse) => {
if (!response.isSuccessful) {
if (hasValue((response as any).errorMessage)) {
this.notificationsService.error('Server Error:', (response as any).errorMessage, new NotificationOptions(-1));
}
} else {
const fieldString = `${field.schema.prefix}.${field.element}${field.qualifier ? `.${field.qualifier}` : ''}`;
this.showNotifications(true, isUpdate, true, {field: fieldString});
return response;
}
}),
isNotEmptyOperator(),
map((response: MetadatafieldSuccessResponse) => {
if (isNotEmpty(response.metadatafield)) {
return response.metadatafield;
}
return this.metadataFieldService.createOrUpdateMetadataField(field).pipe(
getFirstSucceededRemoteDataPayload(),
hasValueOperator(),
tap(() => {
const fieldString = `${field.schema.prefix}.${field.element}${field.qualifier ? `.${field.qualifier}` : ''}`;
this.showNotifications(true, isUpdate, true, {field: fieldString});
})
);
}
@@ -525,38 +264,13 @@ export class RegistryService {
* @param id The id of the metadata field to delete
*/
public deleteMetadataField(id: number): Observable<RestResponse> {
return this.delete(this.metadataFieldsPath, id);
return this.metadataFieldService.deleteAndReturnResponse(`${id}`);
}
/**
* Method that clears a cached metadata field request and returns its REST url
*/
public clearMetadataFieldRequests(): Observable<string> {
return this.halService.getEndpoint(this.metadataFieldsPath).pipe(
tap((href: string) => this.requestService.removeByHrefSubstring(href))
);
}
private delete(path: string, id: number): Observable<RestResponse> {
const requestId = this.requestService.generateRequestId();
const endpoint$ = this.halService.getEndpoint(path).pipe(
isNotEmptyOperator(),
map((endpoint: string) => `${endpoint}/${id}`),
distinctUntilChanged()
);
const request$ = endpoint$.pipe(
take(1),
map((endpoint: string) => new DeleteRequest(requestId, endpoint))
);
// Execute the delete request
request$.pipe(
configureRequest(this.requestService)
).subscribe();
return this.requestService.getByUUID(requestId).pipe(
getResponseFromEntry()
);
return this.metadataFieldService.clearRequests();
}
private showNotifications(success: boolean, edited: boolean, isField: boolean, options: any) {

View File

@@ -5,27 +5,27 @@ export enum ActionType {
/**
* Action of reading, viewing or downloading something
*/
READ = 0,
READ = 'READ',
/**
* Action of modifying something
*/
WRITE = 1,
WRITE = 'WRITE',
/**
* Action of deleting something
*/
DELETE = 2,
DELETE = 'DELETE',
/**
* Action of adding something to a container
*/
ADD = 3,
ADD = 'ADD',
/**
* Action of removing something from a container
*/
REMOVE = 4,
REMOVE = 'REMOVE',
/**
* Action of performing workflow step 1
@@ -50,15 +50,20 @@ export enum ActionType {
/**
* Default Read policies for Bitstreams submitted to container
*/
DEFAULT_BITSTREAM_READ = 9,
DEFAULT_BITSTREAM_READ = 'DEFAULT_BITSTREAM_READ',
/**
* Default Read policies for Items submitted to container
*/
DEFAULT_ITEM_READ = 10,
DEFAULT_ITEM_READ = 'DEFAULT_ITEM_READ',
/**
* Administrative actions
*/
ADMIN = 11,
ADMIN = 'ADMIN',
/**
* Action of withdrawn reading
*/
WITHDRAWN_READ = 'WITHDRAWN_READ'
}

View File

@@ -0,0 +1,25 @@
/**
* Enum representing the Policy Type of a Resource Policy
*/
export enum PolicyType {
/**
* A policy in place during the submission
*/
TYPE_SUBMISSION = 'TYPE_SUBMISSION',
/**
* A policy in place during the approval workflow
*/
TYPE_WORKFLOW = 'TYPE_WORKFLOW',
/**
* A policy that has been inherited from a container (the collection)
*/
TYPE_INHERITED = 'TYPE_INHERITED',
/**
* A policy defined by the user during the submission or workflow phase
*/
TYPE_CUSTOM = 'TYPE_CUSTOM',
}

View File

@@ -0,0 +1,105 @@
import { autoserialize, deserialize, deserializeAs } from 'cerialize';
import { link, typedObject } from '../../cache/builders/build-decorators';
import { IDToUUIDSerializer } from '../../cache/id-to-uuid-serializer';
import { ActionType } from './action-type.model';
import { CacheableObject } from '../../cache/object-cache.reducer';
import { HALLink } from '../../shared/hal-link.model';
import { RESOURCE_POLICY } from './resource-policy.resource-type';
import { excludeFromEquals } from '../../utilities/equals.decorators';
import { ResourceType } from '../../shared/resource-type';
import { PolicyType } from './policy-type.model';
import { Observable } from 'rxjs/internal/Observable';
import { RemoteData } from '../../data/remote-data';
import { GROUP } from '../../eperson/models/group.resource-type';
import { Group } from '../../eperson/models/group.model';
import { EPERSON } from '../../eperson/models/eperson.resource-type';
import { EPerson } from '../../eperson/models/eperson.model';
/**
* Model class for a Resource Policy
*/
@typedObject
export class ResourcePolicy implements CacheableObject {
static type = RESOURCE_POLICY;
/**
* The identifier for this Resource Policy
*/
@autoserialize
id: string;
/**
* The name for this Resource Policy
*/
@autoserialize
name: string;
/**
* The description for this Resource Policy
*/
@autoserialize
description: string;
/**
* The classification or this Resource Policy
*/
@autoserialize
policyType: PolicyType;
/**
* The action that is allowed by this Resource Policy
*/
@autoserialize
action: ActionType;
/**
* The first day of validity of the policy (format YYYY-MM-DD)
*/
@autoserialize
startDate: string;
/**
* The last day of validity of the policy (format YYYY-MM-DD)
*/
@autoserialize
endDate: string;
/**
* The object type
*/
@excludeFromEquals
@autoserialize
type: ResourceType;
/**
* The universally unique identifier for this Resource Policy
* This UUID is generated client-side and isn't used by the backend.
* It is based on the ID, so it will be the same for each refresh.
*/
@deserializeAs(new IDToUUIDSerializer('resource-policy'), 'id')
uuid: string;
/**
* The {@link HALLink}s for this ResourcePolicy
*/
@deserialize
_links: {
eperson: HALLink,
group: HALLink,
self: HALLink,
};
/**
* The eperson linked by this resource policy
* Will be undefined unless the version {@link HALLink} has been resolved.
*/
@link(EPERSON)
eperson?: Observable<RemoteData<EPerson>>;
/**
* The group linked by this resource policy
* Will be undefined unless the version {@link HALLink} has been resolved.
*/
@link(GROUP)
group?: Observable<RemoteData<Group>>;
}

View File

@@ -1,4 +1,4 @@
import { ResourceType } from './resource-type';
import { ResourceType } from '../../shared/resource-type';
/**
* The resource type for ResourcePolicy
@@ -6,4 +6,4 @@ import { ResourceType } from './resource-type';
* Needs to be in a separate file to prevent circular
* dependencies in webpack.
*/
export const RESOURCE_POLICY = new ResourceType('resourcePolicy');
export const RESOURCE_POLICY = new ResourceType('resourcepolicy');

View File

@@ -0,0 +1,319 @@
import { HttpClient } from '@angular/common/http';
import { cold, getTestScheduler, hot } from 'jasmine-marbles';
import { of as observableOf } from 'rxjs';
import { TestScheduler } from 'rxjs/testing';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { RequestService } from '../data/request.service';
import { ResourcePolicyService } from './resource-policy.service';
import { PolicyType } from './models/policy-type.model';
import { ActionType } from './models/action-type.model';
import { FindListOptions } from '../data/request.models';
import { RequestParam } from '../cache/models/request-param.model';
import { PageInfo } from '../shared/page-info.model';
import { PaginatedList } from '../data/paginated-list';
import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils';
import { RequestEntry } from '../data/request.reducer';
import { RestResponse } from '../cache/response.models';
describe('ResourcePolicyService', () => {
let scheduler: TestScheduler;
let service: ResourcePolicyService;
let requestService: RequestService;
let rdbService: RemoteDataBuildService;
let objectCache: ObjectCacheService;
let halService: HALEndpointService;
let responseCacheEntry: RequestEntry;
const resourcePolicy: any = {
id: '1',
name: null,
description: null,
policyType: PolicyType.TYPE_SUBMISSION,
action: ActionType.READ,
startDate: null,
endDate: null,
type: 'resourcepolicy',
uuid: 'resource-policy-1',
_links: {
eperson: {
href: 'https://rest.api/rest/api/eperson'
},
group: {
href: 'https://rest.api/rest/api/group'
},
self: {
href: 'https://rest.api/rest/api/resourcepolicies/1'
},
}
};
const anotherResourcePolicy: any = {
id: '2',
name: null,
description: null,
policyType: PolicyType.TYPE_SUBMISSION,
action: ActionType.WRITE,
startDate: null,
endDate: null,
type: 'resourcepolicy',
uuid: 'resource-policy-2',
_links: {
eperson: {
href: 'https://rest.api/rest/api/eperson'
},
group: {
href: 'https://rest.api/rest/api/group'
},
self: {
href: 'https://rest.api/rest/api/resourcepolicies/1'
},
}
};
const endpointURL = `https://rest.api/rest/api/resourcepolicies`;
const requestURL = `https://rest.api/rest/api/resourcepolicies/${resourcePolicy.id}`;
const requestUUID = '8b3c613a-5a4b-438b-9686-be1d5b4a1c5a';
const resourcePolicyId = '1';
const epersonUUID = '8b39g7ya-5a4b-438b-9686-be1d5b4a1c5a';
const groupUUID = '8b39g7ya-5a4b-36987-9686-be1d5b4a1c5a';
const resourceUUID = '8b39g7ya-5a4b-438b-851f-be1d5b4a1c5a';
const pageInfo = new PageInfo();
const array = [resourcePolicy, anotherResourcePolicy];
const paginatedList = new PaginatedList(pageInfo, array);
const resourcePolicyRD = createSuccessfulRemoteDataObject(resourcePolicy);
const paginatedListRD = createSuccessfulRemoteDataObject(paginatedList);
beforeEach(() => {
scheduler = getTestScheduler();
halService = jasmine.createSpyObj('halService', {
getEndpoint: cold('a', { a: endpointURL })
});
responseCacheEntry = new RequestEntry();
responseCacheEntry.response = new RestResponse(true, 200, 'Success');
requestService = jasmine.createSpyObj('requestService', {
generateRequestId: requestUUID,
configure: true,
removeByHrefSubstring: {},
getByHref: observableOf(responseCacheEntry),
getByUUID: observableOf(responseCacheEntry),
});
rdbService = jasmine.createSpyObj('rdbService', {
buildSingle: hot('a|', {
a: resourcePolicyRD
}),
buildList: hot('a|', {
a: paginatedListRD
}),
});
objectCache = {} as ObjectCacheService;
const notificationsService = {} as NotificationsService;
const http = {} as HttpClient;
const comparator = {} as any;
service = new ResourcePolicyService(
requestService,
rdbService,
objectCache,
halService,
notificationsService,
http,
comparator
);
spyOn((service as any).dataService, 'create').and.callThrough();
spyOn((service as any).dataService, 'delete').and.callThrough();
spyOn((service as any).dataService, 'update').and.callThrough();
spyOn((service as any).dataService, 'findById').and.callThrough();
spyOn((service as any).dataService, 'findByHref').and.callThrough();
spyOn((service as any).dataService, 'searchBy').and.callThrough();
spyOn((service as any).dataService, 'getSearchByHref').and.returnValue(observableOf(requestURL));
});
describe('create', () => {
it('should proxy the call to dataservice.create with eperson UUID', () => {
scheduler.schedule(() => service.create(resourcePolicy, resourceUUID, epersonUUID));
const params = [
new RequestParam('resource', resourceUUID),
new RequestParam('eperson', epersonUUID)
];
scheduler.flush();
expect((service as any).dataService.create).toHaveBeenCalledWith(resourcePolicy, ...params);
});
it('should proxy the call to dataservice.create with group UUID', () => {
scheduler.schedule(() => service.create(resourcePolicy, resourceUUID, null, groupUUID));
const params = [
new RequestParam('resource', resourceUUID),
new RequestParam('group', groupUUID)
];
scheduler.flush();
expect((service as any).dataService.create).toHaveBeenCalledWith(resourcePolicy, ...params);
});
it('should return a RemoteData<ResourcePolicy> for the object with the given id', () => {
const result = service.create(resourcePolicy, resourceUUID, epersonUUID);
const expected = cold('a|', {
a: resourcePolicyRD
});
expect(result).toBeObservable(expected);
});
});
describe('delete', () => {
it('should proxy the call to dataservice.create', () => {
scheduler.schedule(() => service.delete(resourcePolicyId));
scheduler.flush();
expect((service as any).dataService.delete).toHaveBeenCalledWith(resourcePolicyId);
});
});
describe('update', () => {
it('should proxy the call to dataservice.update', () => {
scheduler.schedule(() => service.update(resourcePolicy));
scheduler.flush();
expect((service as any).dataService.update).toHaveBeenCalledWith(resourcePolicy);
});
});
describe('findById', () => {
it('should proxy the call to dataservice.findById', () => {
scheduler.schedule(() => service.findById(resourcePolicyId));
scheduler.flush();
expect((service as any).dataService.findById).toHaveBeenCalledWith(resourcePolicyId);
});
it('should return a RemoteData<ResourcePolicy> for the object with the given id', () => {
const result = service.findById(resourcePolicyId);
const expected = cold('a|', {
a: resourcePolicyRD
});
expect(result).toBeObservable(expected);
});
});
describe('findByHref', () => {
it('should proxy the call to dataservice.findByHref', () => {
scheduler.schedule(() => service.findByHref(requestURL));
scheduler.flush();
expect((service as any).dataService.findByHref).toHaveBeenCalledWith(requestURL);
});
it('should return a RemoteData<ResourcePolicy> for the object with the given URL', () => {
const result = service.findByHref(requestURL);
const expected = cold('a|', {
a: resourcePolicyRD
});
expect(result).toBeObservable(expected);
});
});
describe('searchByEPerson', () => {
it('should proxy the call to dataservice.searchBy', () => {
const options = new FindListOptions();
options.searchParams = [new RequestParam('uuid', epersonUUID)];
scheduler.schedule(() => service.searchByEPerson(epersonUUID));
scheduler.flush();
expect((service as any).dataService.searchBy).toHaveBeenCalledWith((service as any).searchByEPersonMethod, options);
});
it('should proxy the call to dataservice.searchBy with additional search param', () => {
const options = new FindListOptions();
options.searchParams = [
new RequestParam('uuid', epersonUUID),
new RequestParam('resource', resourceUUID),
];
scheduler.schedule(() => service.searchByEPerson(epersonUUID, resourceUUID));
scheduler.flush();
expect((service as any).dataService.searchBy).toHaveBeenCalledWith((service as any).searchByEPersonMethod, options);
});
it('should return a RemoteData<PaginatedList<ResourcePolicy>) for the search', () => {
const result = service.searchByEPerson(epersonUUID, resourceUUID);
const expected = cold('a|', {
a: paginatedListRD
});
expect(result).toBeObservable(expected);
});
});
describe('searchByGroup', () => {
it('should proxy the call to dataservice.searchBy', () => {
const options = new FindListOptions();
options.searchParams = [new RequestParam('uuid', groupUUID)];
scheduler.schedule(() => service.searchByGroup(groupUUID));
scheduler.flush();
expect((service as any).dataService.searchBy).toHaveBeenCalledWith((service as any).searchByGroupMethod, options);
});
it('should proxy the call to dataservice.searchBy with additional search param', () => {
const options = new FindListOptions();
options.searchParams = [
new RequestParam('uuid', groupUUID),
new RequestParam('resource', resourceUUID),
];
scheduler.schedule(() => service.searchByGroup(groupUUID, resourceUUID));
scheduler.flush();
expect((service as any).dataService.searchBy).toHaveBeenCalledWith((service as any).searchByGroupMethod, options);
});
it('should return a RemoteData<PaginatedList<ResourcePolicy>) for the search', () => {
const result = service.searchByGroup(groupUUID);
const expected = cold('a|', {
a: paginatedListRD
});
expect(result).toBeObservable(expected);
});
});
describe('searchByResource', () => {
it('should proxy the call to dataservice.searchBy', () => {
const options = new FindListOptions();
options.searchParams = [new RequestParam('uuid', resourceUUID)];
scheduler.schedule(() => service.searchByResource(resourceUUID));
scheduler.flush();
expect((service as any).dataService.searchBy).toHaveBeenCalledWith((service as any).searchByResourceMethod, options);
});
it('should proxy the call to dataservice.searchBy with additional search param', () => {
const action = ActionType.READ;
const options = new FindListOptions();
options.searchParams = [
new RequestParam('uuid', resourceUUID),
new RequestParam('action', action),
];
scheduler.schedule(() => service.searchByResource(resourceUUID, action));
scheduler.flush();
expect((service as any).dataService.searchBy).toHaveBeenCalledWith((service as any).searchByResourceMethod, options);
});
it('should return a RemoteData<PaginatedList<ResourcePolicy>) for the search', () => {
const result = service.searchByResource(resourceUUID);
const expected = cold('a|', {
a: paginatedListRD
});
expect(result).toBeObservable(expected);
});
});
});

View File

@@ -0,0 +1,193 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
import { dataService } from '../cache/builders/build-decorators';
import { DataService } from '../data/data.service';
import { RequestService } from '../data/request.service';
import { FindListOptions } from '../data/request.models';
import { Collection } from '../shared/collection.model';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { ResourcePolicy } from './models/resource-policy.model';
import { RemoteData } from '../data/remote-data';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { CoreState } from '../core.reducers';
import { ObjectCacheService } from '../cache/object-cache.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { RESOURCE_POLICY } from './models/resource-policy.resource-type';
import { ChangeAnalyzer } from '../data/change-analyzer';
import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service';
import { PaginatedList } from '../data/paginated-list';
import { ActionType } from './models/action-type.model';
import { RequestParam } from '../cache/models/request-param.model';
import { isNotEmpty } from '../../shared/empty.util';
/* tslint:disable:max-classes-per-file */
/**
* A private DataService implementation to delegate specific methods to.
*/
class DataServiceImpl extends DataService<ResourcePolicy> {
protected linkPath = 'resourcepolicies';
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected store: Store<CoreState>,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: ChangeAnalyzer<ResourcePolicy>) {
super();
}
}
/**
* A service responsible for fetching/sending data from/to the REST API on the resourcepolicies endpoint
*/
@Injectable()
@dataService(RESOURCE_POLICY)
export class ResourcePolicyService {
private dataService: DataServiceImpl;
protected searchByEPersonMethod = 'eperson';
protected searchByGroupMethod = 'group';
protected searchByResourceMethod = 'resource';
constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected http: HttpClient,
protected comparator: DefaultChangeAnalyzer<ResourcePolicy>) {
this.dataService = new DataServiceImpl(requestService, rdbService, null, objectCache, halService, notificationsService, http, comparator);
}
/**
* Create a new ResourcePolicy on the server, and store the response
* in the object cache
*
* @param {ResourcePolicy} resourcePolicy
* The resource policy to create
* @param {string} resourceUUID
* The uuid of the resource target of the policy
* @param {string} epersonUUID
* The uuid of the eperson that will be grant of the permission. Exactly one of eperson or group is required
* @param {string} groupUUID
* The uuid of the group that will be grant of the permission. Exactly one of eperson or group is required
*/
create(resourcePolicy: ResourcePolicy, resourceUUID: string, epersonUUID?: string, groupUUID?: string): Observable<RemoteData<ResourcePolicy>> {
const params = [];
params.push(new RequestParam('resource', resourceUUID));
if (isNotEmpty(epersonUUID)) {
params.push(new RequestParam('eperson', epersonUUID));
} else if (isNotEmpty(groupUUID)) {
params.push(new RequestParam('group', groupUUID));
}
return this.dataService.create(resourcePolicy, ...params);
}
/**
* Delete an existing ResourcePolicy on the server
*
* @param resourcePolicyID The resource policy's id to be removed
* @return an observable that emits true when the deletion was successful, false when it failed
*/
delete(resourcePolicyID: string): Observable<boolean> {
return this.dataService.delete(resourcePolicyID);
}
/**
* Add a new patch to the object cache
* The patch is derived from the differences between the given object and its version in the object cache
* @param {ResourcePolicy} object The given object
*/
update(object: ResourcePolicy): Observable<RemoteData<ResourcePolicy>> {
return this.dataService.update(object);
}
/**
* Returns an observable of {@link RemoteData} of a {@link ResourcePolicy}, based on an href, with a list of {@link FollowLinkConfig},
* to automatically resolve {@link HALLink}s of the {@link ResourcePolicy}
* @param href The url of {@link ResourcePolicy} we want to retrieve
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
findByHref(href: string, ...linksToFollow: Array<FollowLinkConfig<ResourcePolicy>>): Observable<RemoteData<ResourcePolicy>> {
return this.dataService.findByHref(href, ...linksToFollow);
}
/**
* Returns an observable of {@link RemoteData} of a {@link ResourcePolicy}, based on its ID, with a list of {@link FollowLinkConfig},
* to automatically resolve {@link HALLink}s of the object
* @param id ID of {@link ResourcePolicy} we want to retrieve
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
findById(id: string, ...linksToFollow: Array<FollowLinkConfig<ResourcePolicy>>): Observable<RemoteData<ResourcePolicy>> {
return this.dataService.findById(id, ...linksToFollow);
}
/**
* Return the defaultAccessConditions {@link ResourcePolicy} list for a given {@link Collection}
*
* @param collection the {@link Collection} to retrieve the defaultAccessConditions for
* @param findListOptions the {@link FindListOptions} for the request
*/
getDefaultAccessConditionsFor(collection: Collection, findListOptions?: FindListOptions): Observable<RemoteData<PaginatedList<ResourcePolicy>>> {
return this.dataService.findAllByHref(collection._links.defaultAccessConditions.href, findListOptions);
}
/**
* Return the {@link ResourcePolicy} list for a {@link EPerson}
*
* @param UUID UUID of a given {@link EPerson}
* @param resourceUUID Limit the returned policies to the specified DSO
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
searchByEPerson(UUID: string, resourceUUID?: string, ...linksToFollow: Array<FollowLinkConfig<ResourcePolicy>>): Observable<RemoteData<PaginatedList<ResourcePolicy>>> {
const options = new FindListOptions();
options.searchParams = [new RequestParam('uuid', UUID)];
if (isNotEmpty(resourceUUID)) {
options.searchParams.push(new RequestParam('resource', resourceUUID))
}
return this.dataService.searchBy(this.searchByEPersonMethod, options, ...linksToFollow)
}
/**
* Return the {@link ResourcePolicy} list for a {@link Group}
*
* @param UUID UUID of a given {@link Group}
* @param resourceUUID Limit the returned policies to the specified DSO
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
searchByGroup(UUID: string, resourceUUID?: string, ...linksToFollow: Array<FollowLinkConfig<ResourcePolicy>>): Observable<RemoteData<PaginatedList<ResourcePolicy>>> {
const options = new FindListOptions();
options.searchParams = [new RequestParam('uuid', UUID)];
if (isNotEmpty(resourceUUID)) {
options.searchParams.push(new RequestParam('resource', resourceUUID))
}
return this.dataService.searchBy(this.searchByGroupMethod, options, ...linksToFollow)
}
/**
* Return the {@link ResourcePolicy} list for a given DSO
*
* @param UUID UUID of a given DSO
* @param action Limit the returned policies to the specified {@link ActionType}
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
*/
searchByResource(UUID: string, action?: ActionType, ...linksToFollow: Array<FollowLinkConfig<ResourcePolicy>>): Observable<RemoteData<PaginatedList<ResourcePolicy>>> {
const options = new FindListOptions();
options.searchParams = [new RequestParam('uuid', UUID)];
if (isNotEmpty(action)) {
options.searchParams.push(new RequestParam('action', action))
}
return this.dataService.searchBy(this.searchByResourceMethod, options, ...linksToFollow)
}
}

View File

@@ -1,8 +1,15 @@
import { deserialize, inheritSerialization } from 'cerialize';
import { typedObject } from '../cache/builders/build-decorators';
import { Observable } from 'rxjs';
import { link, typedObject } from '../cache/builders/build-decorators';
import { BUNDLE } from './bundle.resource-type';
import { DSpaceObject } from './dspace-object.model';
import { HALLink } from './hal-link.model';
import { RemoteData } from '../data/remote-data';
import { PaginatedList } from '../data/paginated-list';
import { BITSTREAM } from './bitstream.resource-type';
import { Bitstream } from './bitstream.model';
@typedObject
@inheritSerialization(DSpaceObject)
@@ -17,5 +24,19 @@ export class Bundle extends DSpaceObject {
self: HALLink;
primaryBitstream: HALLink;
bitstreams: HALLink;
}
};
/**
* The primary Bitstream of this Bundle
* Will be undefined unless the primaryBitstream {@link HALLink} has been resolved.
*/
@link(BITSTREAM)
primaryBitstream?: Observable<RemoteData<Bitstream>>;
/**
* The list of Bitstreams that are direct children of this Bundle
* Will be undefined unless the bitstreams {@link HALLink} has been resolved.
*/
@link(BITSTREAM, true)
bitstreams?: Observable<RemoteData<PaginatedList<Bitstream>>>;
}

View File

@@ -10,8 +10,8 @@ import { DSpaceObject } from './dspace-object.model';
import { HALLink } from './hal-link.model';
import { License } from './license.model';
import { LICENSE } from './license.resource-type';
import { ResourcePolicy } from './resource-policy.model';
import { RESOURCE_POLICY } from './resource-policy.resource-type';
import { ResourcePolicy } from '../resource-policy/models/resource-policy.model';
import { RESOURCE_POLICY } from '../resource-policy/models/resource-policy.resource-type';
import { COMMUNITY } from './community.resource-type';
import { Community } from './community.model';
import { ChildHALResource } from './child-hal-resource.model';

View File

@@ -67,6 +67,10 @@ export const getSucceededRemoteData = () =>
<T>(source: Observable<RemoteData<T>>): Observable<RemoteData<T>> =>
source.pipe(find((rd: RemoteData<T>) => rd.hasSucceeded));
export const getSucceededRemoteWithNotEmptyData = () =>
<T>(source: Observable<RemoteData<T>>): Observable<RemoteData<T>> =>
source.pipe(find((rd: RemoteData<T>) => rd.hasSucceeded && isNotEmpty(rd.payload)));
/**
* Get the first successful remotely retrieved object
*
@@ -84,6 +88,23 @@ export const getFirstSucceededRemoteDataPayload = () =>
getRemoteDataPayload()
);
/**
* Get the first successful remotely retrieved object with not empty payload
*
* You usually don't want to use this, it is a code smell.
* Work with the RemoteData object instead, that way you can
* handle loading and errors correctly.
*
* These operators were created as a first step in refactoring
* out all the instances where this is used incorrectly.
*/
export const getFirstSucceededRemoteDataWithNotEmptyPayload = () =>
<T>(source: Observable<RemoteData<T>>): Observable<T> =>
source.pipe(
getSucceededRemoteWithNotEmptyData(),
getRemoteDataPayload()
);
/**
* Get the all successful remotely retrieved objects
*

View File

@@ -1,58 +0,0 @@
import { autoserialize, deserialize, deserializeAs } from 'cerialize';
import { typedObject } from '../cache/builders/build-decorators';
import { IDToUUIDSerializer } from '../cache/id-to-uuid-serializer';
import { ActionType } from '../cache/models/action-type.model';
import { CacheableObject } from '../cache/object-cache.reducer';
import { excludeFromEquals } from '../utilities/equals.decorators';
import { HALLink } from './hal-link.model';
import { RESOURCE_POLICY } from './resource-policy.resource-type';
import { ResourceType } from './resource-type';
/**
* Model class for a Resource Policy
*/
@typedObject
export class ResourcePolicy implements CacheableObject {
static type = RESOURCE_POLICY;
/**
* The object type
*/
@excludeFromEquals
@autoserialize
type: ResourceType;
/**
* The action that is allowed by this Resource Policy
*/
@autoserialize
action: ActionType;
/**
* The name for this Resource Policy
*/
@autoserialize
name: string;
/**
* The uuid of the Group this Resource Policy applies to
*/
@autoserialize
groupUUID: string;
/**
* The universally unique identifier for this Resource Policy
* This UUID is generated client-side and isn't used by the backend.
* It is based on the ID, so it will be the same for each refresh.
*/
@deserializeAs(new IDToUUIDSerializer('resource-policy'), 'id')
uuid: string;
/**
* The {@link HALLink}s for this ResourcePolicy
*/
@deserialize
_links: {
self: HALLink,
}
}

View File

@@ -0,0 +1,43 @@
<div class="form-group w-100 pr-2 pl-2">
<input *ngIf="searchField"
type="search"
class="form-control w-100"
(click)="$event.stopPropagation();"
placeholder="{{ 'submission.sections.general.search-collection' | translate }}"
[formControl]="searchField"
#searchFieldEl>
</div>
<div class="dropdown-divider"></div>
<div
class="scrollable-menu"
aria-labelledby="dropdownMenuButton"
(scroll)="onScroll($event)">
<div
infiniteScroll
[infiniteScrollDistance]="2"
[infiniteScrollThrottle]="300"
[infiniteScrollUpDistance]="1.5"
[infiniteScrollContainer]="'.scrollable-menu'"
[fromRoot]="true"
(scrolled)="onScrollDown()">
<button class="dropdown-item disabled" *ngIf="searchListCollection?.length == 0 && !(isLoadingList | async)">
{{'submission.sections.general.no-collection' | translate}}
</button>
<button
*ngFor="let listItem of searchListCollection"
class="dropdown-item collection-item"
title="{{ listItem.collection.name }}"
(click)="onSelect(listItem)">
<ul class="list-unstyled mb-0">
<li class="list-item text-truncate text-secondary" *ngFor="let item of listItem.communities">
{{ item.name}} <i class="fa fa-level-down" aria-hidden="true"></i>
</li>
<li class="list-item text-truncate text-primary font-weight-bold">{{ listItem.collection.name}}</li>
</ul>
</button>
<button class="dropdown-item disabled" *ngIf="(isLoadingList | async)" >
<ds-loading message="{{'loading.default' | translate}}">
</ds-loading>
</button>
</div>
</div>

View File

@@ -0,0 +1,15 @@
.scrollable-menu {
height: auto;
max-height: $dropdown-menu-max-height;
overflow-x: hidden;
}
.collection-item {
border-bottom: $dropdown-border-width solid $dropdown-border-color;
}
#collectionControlsDropdownMenu {
outline: 0;
left: 0 !important;
box-shadow: $btn-focus-box-shadow;
}

View File

@@ -0,0 +1,241 @@
import { async, ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { CollectionDropdownComponent } from './collection-dropdown.component';
import { FollowLinkConfig } from '../utils/follow-link-config.model';
import { Observable, of } from 'rxjs';
import { RemoteData } from 'src/app/core/data/remote-data';
import { PaginatedList } from 'src/app/core/data/paginated-list';
import { cold, getTestScheduler, hot } from 'jasmine-marbles';
import { createSuccessfulRemoteDataObject } from '../remote-data.utils';
import { PageInfo } from 'src/app/core/shared/page-info.model';
import { Collection } from '../../core/shared/collection.model';
import { NO_ERRORS_SCHEMA, ChangeDetectorRef, ElementRef } from '@angular/core';
import { CollectionDataService } from 'src/app/core/data/collection-data.service';
import { FindListOptions } from 'src/app/core/data/request.models';
import { TranslateModule, TranslateLoader } from '@ngx-translate/core';
import { TranslateLoaderMock } from '../mocks/translate-loader.mock';
import { TestScheduler } from 'rxjs/testing';
import { By } from '@angular/platform-browser';
import { Community } from 'src/app/core/shared/community.model';
const community: Community = Object.assign(new Community(), {
id: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88',
uuid: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88',
name: 'Community 1'
});
const collections: Collection[] = [
Object.assign(new Collection(), {
id: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88',
name: 'Collection 1',
metadata: [
{
key: 'dc.title',
language: 'en_US',
value: 'Community 1-Collection 1'
}],
parentCommunity: of(
new RemoteData(false, false, true, undefined, community, 200)
)
}),
Object.assign(new Collection(), {
id: '59ee713b-ee53-4220-8c3f-9860dc84fe33',
name: 'Collection 2',
metadata: [
{
key: 'dc.title',
language: 'en_US',
value: 'Community 1-Collection 2'
}],
parentCommunity: of(
new RemoteData(false, false, true, undefined, community, 200)
)
}),
Object.assign(new Collection(), {
id: 'e9dbf393-7127-415f-8919-55be34a6e9ed',
name: 'Collection 3',
metadata: [
{
key: 'dc.title',
language: 'en_US',
value: 'Community 1-Collection 3'
}],
parentCommunity: of(
new RemoteData(false, false, true, undefined, community, 200)
)
}),
Object.assign(new Collection(), {
id: '59da2ff0-9bf4-45bf-88be-e35abd33f304',
name: 'Collection 4',
metadata: [
{
key: 'dc.title',
language: 'en_US',
value: 'Community 1-Collection 4'
}],
parentCommunity: of(
new RemoteData(false, false, true, undefined, community, 200)
)
}),
Object.assign(new Collection(), {
id: 'a5159760-f362-4659-9e81-e3253ad91ede',
name: 'Collection 5',
metadata: [
{
key: 'dc.title',
language: 'en_US',
value: 'Community 1-Collection 5'
}],
parentCommunity: of(
new RemoteData(false, false, true, undefined, community, 200)
)
})
];
const listElementMock = {
communities: [
{
id: 'ce64f48e-2c9b-411a-ac36-ee429c0e6a88',
name: 'Community 1'
}
],
collection: {
id: 'e9dbf393-7127-415f-8919-55be34a6e9ed',
uuid: 'e9dbf393-7127-415f-8919-55be34a6e9ed',
name: 'Collection 3'
}
};
// tslint:disable-next-line: max-classes-per-file
class CollectionDataServiceMock {
getAuthorizedCollection(query: string, options: FindListOptions = {}, ...linksToFollow: Array<FollowLinkConfig<Collection>>): Observable<RemoteData<PaginatedList<Collection>>> {
return of(
createSuccessfulRemoteDataObject(
new PaginatedList(new PageInfo(), collections)
)
);
}
}
describe('CollectionDropdownComponent', () => {
let component: CollectionDropdownComponent;
let fixture: ComponentFixture<CollectionDropdownComponent>;
let scheduler: TestScheduler;
const searchedCollection = 'TEXT';
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useClass: TranslateLoaderMock
}
})
],
declarations: [ CollectionDropdownComponent ],
providers: [
{provide: CollectionDataService, useClass: CollectionDataServiceMock},
{provide: ChangeDetectorRef, useValue: {}},
{provide: ElementRef, userValue: {}}
],
schemas: [NO_ERRORS_SCHEMA]
})
.compileComponents();
}));
beforeEach(() => {
scheduler = getTestScheduler();
fixture = TestBed.createComponent(CollectionDropdownComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should populate collections list with five items', () => {
const elements = fixture.debugElement.queryAll(By.css('.collection-item'));
expect(elements.length).toEqual(5);
});
it('should trigger onSelect method when select a new collection from list', fakeAsync(() => {
spyOn(component, 'onSelect');
const collectionItem = fixture.debugElement.query(By.css('.collection-item:nth-child(2)'));
collectionItem.triggerEventHandler('click', null);
fixture.detectChanges();
tick();
fixture.whenStable().then(() => {
expect(component.onSelect).toHaveBeenCalled();
});
}));
it('should init component with collection list', fakeAsync(() => {
spyOn(component.subs, 'push').and.callThrough();
spyOn(component, 'resetPagination').and.callThrough();
spyOn(component, 'populateCollectionList').and.callThrough();
component.ngOnInit();
tick();
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(component.subs.push).toHaveBeenCalled();
expect(component.resetPagination).toHaveBeenCalled();
expect(component.populateCollectionList).toHaveBeenCalled();
});
}));
it('should emit collectionChange event when selecting a new collection', () => {
spyOn(component.selectionChange, 'emit').and.callThrough();
component.ngOnInit();
component.onSelect(listElementMock as any);
fixture.detectChanges();
expect(component.selectionChange.emit).toHaveBeenCalledWith(listElementMock as any);
});
it('should reset collections list after reset of searchField', fakeAsync(() => {
spyOn(component.subs, 'push').and.callThrough();
spyOn(component, 'reset').and.callThrough();
spyOn(component.searchField, 'setValue').and.callThrough();
spyOn(component, 'resetPagination').and.callThrough();
spyOn(component, 'populateCollectionList').and.callThrough();
component.reset();
const input = fixture.debugElement.query(By.css('input.form-control'));
const el = input.nativeElement;
el.value = searchedCollection;
el.dispatchEvent(new Event('input'));
fixture.detectChanges();
tick(500);
fixture.whenStable().then(() => {
expect(component.reset).toHaveBeenCalled();
expect(component.searchField.setValue).toHaveBeenCalledWith('');
expect(component.resetPagination).toHaveBeenCalled();
expect(component.currentQuery).toEqual('');
expect(component.populateCollectionList).toHaveBeenCalledWith(component.currentQuery, component.currentPage);
expect(component.searchListCollection).toEqual(collections as any);
expect(component.subs.push).toHaveBeenCalled();
});
}));
it('should reset searchField when dropdown menu has been closed', () => {
spyOn(component.searchField, 'setValue').and.callThrough();
component.reset();
expect(component.searchField.setValue).toHaveBeenCalled();
});
it('should change loader status', () => {
spyOn(component.isLoadingList, 'next').and.callThrough();
component.hideShowLoader(true);
expect(component.isLoadingList.next).toHaveBeenCalledWith(true);
});
it('reset pagination fields', () => {
component.resetPagination();
expect(component.currentPage).toEqual(1);
expect(component.currentQuery).toEqual('');
expect(component.hasNextPage).toEqual(true);
expect(component.searchListCollection).toEqual([]);
});
});

View File

@@ -0,0 +1,236 @@
import { Component, OnInit, HostListener, ChangeDetectorRef, OnDestroy, Output, EventEmitter, ElementRef } from '@angular/core';
import { FormControl } from '@angular/forms';
import { Observable, Subscription, BehaviorSubject } from 'rxjs';
import { hasValue } from '../empty.util';
import { map, mergeMap, startWith, debounceTime, distinctUntilChanged, switchMap, reduce } from 'rxjs/operators';
import { RemoteData } from 'src/app/core/data/remote-data';
import { FindListOptions } from 'src/app/core/data/request.models';
import { PaginatedList } from 'src/app/core/data/paginated-list';
import { Community } from 'src/app/core/shared/community.model';
import { CollectionDataService } from 'src/app/core/data/collection-data.service';
import { Collection } from '../../core/shared/collection.model';
import { followLink } from '../utils/follow-link-config.model';
import { getFirstSucceededRemoteDataPayload, getSucceededRemoteWithNotEmptyData } from '../../core/shared/operators';
/**
* An interface to represent a collection entry
*/
interface CollectionListEntryItem {
id: string;
uuid: string;
name: string;
}
/**
* An interface to represent an entry in the collection list
*/
interface CollectionListEntry {
communities: CollectionListEntryItem[],
collection: CollectionListEntryItem
}
@Component({
selector: 'ds-collection-dropdown',
templateUrl: './collection-dropdown.component.html',
styleUrls: ['./collection-dropdown.component.scss']
})
export class CollectionDropdownComponent implements OnInit, OnDestroy {
/**
* The search form control
* @type {FormControl}
*/
public searchField: FormControl = new FormControl();
/**
* The collection list obtained from a search
* @type {Observable<CollectionListEntry[]>}
*/
public searchListCollection$: Observable<CollectionListEntry[]>;
/**
* A boolean representing if dropdown list is scrollable to the bottom
* @type {boolean}
*/
private scrollableBottom = false;
/**
* A boolean representing if dropdown list is scrollable to the top
* @type {boolean}
*/
private scrollableTop = false;
/**
* Array to track all subscriptions and unsubscribe them onDestroy
* @type {Array}
*/
public subs: Subscription[] = [];
/**
* The list of collection to render
*/
searchListCollection: CollectionListEntry[] = [];
@Output() selectionChange = new EventEmitter<CollectionListEntry>();
/**
* A boolean representing if the loader is visible or not
*/
isLoadingList: BehaviorSubject<boolean> = new BehaviorSubject(false);
/**
* A numeric representig current page
*/
currentPage: number;
/**
* A boolean representing if exist another page to render
*/
hasNextPage: boolean;
/**
* Current seach query used to filter collection list
*/
currentQuery: string;
constructor(
private changeDetectorRef: ChangeDetectorRef,
private collectionDataService: CollectionDataService,
private el: ElementRef
) { }
/**
* Method called on mousewheel event, it prevent the page scroll
* when arriving at the top/bottom of dropdown menu
*
* @param event
* mousewheel event
*/
@HostListener('mousewheel', ['$event']) onMousewheel(event) {
if (event.wheelDelta > 0 && this.scrollableTop) {
event.preventDefault();
}
if (event.wheelDelta < 0 && this.scrollableBottom) {
event.preventDefault();
}
}
/**
* Initialize collection list
*/
ngOnInit() {
this.subs.push(this.searchField.valueChanges.pipe(
debounceTime(500),
distinctUntilChanged(),
startWith('')
).subscribe(
(next) => {
if (hasValue(next) && next !== this.currentQuery) {
this.resetPagination();
this.currentQuery = next;
this.populateCollectionList(this.currentQuery, this.currentPage);
}
}
));
// Workaround for prevent the scroll of main page when this component is placed in a dialog
setTimeout(() => this.el.nativeElement.querySelector('input').focus(), 0);
}
/**
* Check if dropdown scrollbar is at the top or bottom of the dropdown list
*
* @param event
*/
onScroll(event) {
this.scrollableBottom = (event.target.scrollTop + event.target.clientHeight === event.target.scrollHeight);
this.scrollableTop = (event.target.scrollTop === 0);
}
/**
* Method used from infitity scroll for retrive more data on scroll down
*/
onScrollDown() {
if ( this.hasNextPage ) {
this.populateCollectionList(this.currentQuery, ++this.currentPage);
}
}
/**
* Emit a [selectionChange] event when a new collection is selected from list
*
* @param event
* the selected [CollectionListEntry]
*/
onSelect(event: CollectionListEntry) {
this.selectionChange.emit(event);
}
/**
* Method called for populate the collection list
* @param query text for filter the collection list
* @param page page number
*/
populateCollectionList(query: string, page: number) {
this.isLoadingList.next(true);
// Set the pagination info
const findOptions: FindListOptions = {
elementsPerPage: 10,
currentPage: page
};
this.searchListCollection$ = this.collectionDataService
.getAuthorizedCollection(query, findOptions, followLink('parentCommunity'))
.pipe(
getSucceededRemoteWithNotEmptyData(),
switchMap((collections: RemoteData<PaginatedList<Collection>>) => {
if ( (this.searchListCollection.length + findOptions.elementsPerPage) >= collections.payload.totalElements ) {
this.hasNextPage = false;
}
return collections.payload.page;
}),
mergeMap((collection: Collection) => collection.parentCommunity.pipe(
getFirstSucceededRemoteDataPayload(),
map((community: Community) => ({
communities: [{ id: community.id, name: community.name }],
collection: { id: collection.id, uuid: collection.id, name: collection.name }
})
))),
reduce((acc: any, value: any) => [...acc, ...value], []),
startWith([])
);
this.subs.push(this.searchListCollection$.subscribe(
(next) => { this.searchListCollection.push(...next); }, undefined,
() => { this.hideShowLoader(false); this.changeDetectorRef.detectChanges(); }
));
}
/**
* Unsubscribe from all subscriptions
*/
ngOnDestroy(): void {
this.subs.filter((sub) => hasValue(sub)).forEach((sub) => sub.unsubscribe());
}
/**
* Reset search form control
*/
reset() {
this.searchField.setValue('');
}
/**
* Reset pagination values
*/
resetPagination() {
this.currentPage = 1;
this.currentQuery = '';
this.hasNextPage = true;
this.searchListCollection = [];
}
/**
* Hide/Show the collection list loader
* @param hideShow true for show, false otherwise
*/
hideShowLoader(hideShow: boolean) {
this.isLoadingList.next(hideShow);
}
}

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