mirror of
https://github.com/DSpace/dspace-angular.git
synced 2025-10-07 01:54:15 +00:00
Merge branch 'main' into CST-4320
# Conflicts: # src/app/+item-page/full/full-item-page.component.ts
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
# Docker Compose files
|
||||
|
||||
***
|
||||
:warning: **NOT PRODUCTION READY** The below Docker Compose resources are not guaranteed "production ready" at this time. They have been built for development/testing only. Therefore, DSpace Docker images may not be fully secured or up-to-date. While you are welcome to base your own images on these DSpace images/resources, these should not be used "as is" in any production scenario.
|
||||
***
|
||||
|
||||
## docker directory
|
||||
- docker-compose.yml
|
||||
- Starts DSpace Angular with Docker Compose from the current branch. This file assumes that a DSpace 7 REST instance will also be started in Docker.
|
||||
|
@@ -531,14 +531,17 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
|
||||
* Create menu sections dependent on whether or not the current user can manage access control groups
|
||||
*/
|
||||
createAccessControlMenuSections() {
|
||||
this.authorizationService.isAuthorized(FeatureID.CanManageGroups).subscribe((authorized) => {
|
||||
observableCombineLatest(
|
||||
this.authorizationService.isAuthorized(FeatureID.AdministratorOf),
|
||||
this.authorizationService.isAuthorized(FeatureID.CanManageGroups)
|
||||
).subscribe(([isSiteAdmin, canManageGroups]) => {
|
||||
const menuList = [
|
||||
/* Access Control */
|
||||
{
|
||||
id: 'access_control_people',
|
||||
parentID: 'access_control',
|
||||
active: false,
|
||||
visible: authorized,
|
||||
visible: isSiteAdmin,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.access_control_people',
|
||||
@@ -549,7 +552,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
|
||||
id: 'access_control_groups',
|
||||
parentID: 'access_control',
|
||||
active: false,
|
||||
visible: authorized,
|
||||
visible: canManageGroups,
|
||||
model: {
|
||||
type: MenuItemType.LINK,
|
||||
text: 'menu.section.access_control_groups',
|
||||
@@ -571,7 +574,7 @@ export class AdminSidebarComponent extends MenuComponent implements OnInit {
|
||||
{
|
||||
id: 'access_control',
|
||||
active: false,
|
||||
visible: authorized,
|
||||
visible: canManageGroups || isSiteAdmin,
|
||||
model: {
|
||||
type: MenuItemType.TEXT,
|
||||
text: 'menu.section.access_control'
|
||||
|
@@ -0,0 +1,10 @@
|
||||
<div class="container">
|
||||
<ds-resource-policies [resourceType]="'bitstream'" [resourceUUID]="(dsoRD$ | async)?.payload?.id"></ds-resource-policies>
|
||||
<div class="button-row bottom">
|
||||
<div class="text-right">
|
||||
<a [routerLink]="['/bitstreams', (dsoRD$ | async)?.payload?.id, 'edit']" role="button" class="btn btn-outline-secondary mr-1">
|
||||
<i class="fas fa-arrow-left"></i> {{'bitstream.edit.return' | translate}}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@@ -0,0 +1,84 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ChangeDetectorRef, NO_ERRORS_SCHEMA } from '@angular/core';
|
||||
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
import { cold } from 'jasmine-marbles';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||
import { BitstreamAuthorizationsComponent } from './bitstream-authorizations.component';
|
||||
import { Bitstream } from '../../core/shared/bitstream.model';
|
||||
import { createSuccessfulRemoteDataObject } from '../../shared/remote-data.utils';
|
||||
import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock';
|
||||
|
||||
describe('BitstreamAuthorizationsComponent', () => {
|
||||
let comp: BitstreamAuthorizationsComponent<DSpaceObject>;
|
||||
let fixture: ComponentFixture<BitstreamAuthorizationsComponent<any>>;
|
||||
|
||||
const bitstream = Object.assign(new Bitstream(), {
|
||||
sizeBytes: 10000,
|
||||
metadata: {
|
||||
'dc.title': [
|
||||
{
|
||||
value: 'file name',
|
||||
language: null
|
||||
}
|
||||
]
|
||||
},
|
||||
_links: {
|
||||
content: { href: 'file-selflink' }
|
||||
}
|
||||
});
|
||||
|
||||
const bitstreamRD = createSuccessfulRemoteDataObject(bitstream);
|
||||
|
||||
const routeStub = {
|
||||
data: observableOf({
|
||||
bitstream: bitstreamRD
|
||||
})
|
||||
};
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useClass: TranslateLoaderMock
|
||||
}
|
||||
})
|
||||
],
|
||||
declarations: [BitstreamAuthorizationsComponent],
|
||||
providers: [
|
||||
{ provide: ActivatedRoute, useValue: routeStub },
|
||||
ChangeDetectorRef,
|
||||
BitstreamAuthorizationsComponent,
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(BitstreamAuthorizationsComponent);
|
||||
comp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
comp = null;
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(comp).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should init dso remote data properly', (done) => {
|
||||
const expected = cold('(a|)', { a: bitstreamRD });
|
||||
expect(comp.dsoRD$).toBeObservable(expected);
|
||||
done();
|
||||
});
|
||||
});
|
@@ -0,0 +1,40 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
import { Observable } from 'rxjs';
|
||||
import { first, map } from 'rxjs/operators';
|
||||
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { DSpaceObject } from '../../core/shared/dspace-object.model';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-collection-authorizations',
|
||||
templateUrl: './bitstream-authorizations.component.html',
|
||||
})
|
||||
/**
|
||||
* Component that handles the Collection Authorizations
|
||||
*/
|
||||
export class BitstreamAuthorizationsComponent<TDomain extends DSpaceObject> implements OnInit {
|
||||
|
||||
/**
|
||||
* The initial DSO object
|
||||
*/
|
||||
public dsoRD$: Observable<RemoteData<TDomain>>;
|
||||
|
||||
/**
|
||||
* Initialize instance variables
|
||||
*
|
||||
* @param {ActivatedRoute} route
|
||||
*/
|
||||
constructor(
|
||||
private route: ActivatedRoute
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the component, setting up the collection
|
||||
*/
|
||||
ngOnInit(): void {
|
||||
this.dsoRD$ = this.route.data.pipe(first(), map((data) => data.bitstream));
|
||||
}
|
||||
}
|
@@ -4,8 +4,14 @@ import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream
|
||||
import { AuthenticatedGuard } from '../core/auth/authenticated.guard';
|
||||
import { BitstreamPageResolver } from './bitstream-page.resolver';
|
||||
import { BitstreamDownloadPageComponent } from '../shared/bitstream-download-page/bitstream-download-page.component';
|
||||
import { ResourcePolicyTargetResolver } from '../shared/resource-policies/resolvers/resource-policy-target.resolver';
|
||||
import { ResourcePolicyCreateComponent } from '../shared/resource-policies/create/resource-policy-create.component';
|
||||
import { ResourcePolicyResolver } from '../shared/resource-policies/resolvers/resource-policy.resolver';
|
||||
import { ResourcePolicyEditComponent } from '../shared/resource-policies/edit/resource-policy-edit.component';
|
||||
import { BitstreamAuthorizationsComponent } from './bitstream-authorizations/bitstream-authorizations.component';
|
||||
|
||||
const EDIT_BITSTREAM_PATH = ':id/edit';
|
||||
const EDIT_BITSTREAM_AUTHORIZATIONS_PATH = ':id/authorizations';
|
||||
|
||||
/**
|
||||
* Routing module to help navigate Bitstream pages
|
||||
@@ -27,6 +33,36 @@ const EDIT_BITSTREAM_PATH = ':id/edit';
|
||||
bitstream: BitstreamPageResolver
|
||||
},
|
||||
canActivate: [AuthenticatedGuard]
|
||||
},
|
||||
{
|
||||
path: EDIT_BITSTREAM_AUTHORIZATIONS_PATH,
|
||||
|
||||
children: [
|
||||
{
|
||||
path: 'create',
|
||||
resolve: {
|
||||
resourcePolicyTarget: ResourcePolicyTargetResolver
|
||||
},
|
||||
component: ResourcePolicyCreateComponent,
|
||||
data: { title: 'resource-policies.create.page.title', showBreadcrumbs: true }
|
||||
},
|
||||
{
|
||||
path: 'edit',
|
||||
resolve: {
|
||||
resourcePolicy: ResourcePolicyResolver
|
||||
},
|
||||
component: ResourcePolicyEditComponent,
|
||||
data: { title: 'resource-policies.edit.page.title', showBreadcrumbs: true }
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
resolve: {
|
||||
bitstream: BitstreamPageResolver
|
||||
},
|
||||
component: BitstreamAuthorizationsComponent,
|
||||
data: { title: 'bitstream.edit.authorizations.title', showBreadcrumbs: true }
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
],
|
||||
|
@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { EditBitstreamPageComponent } from './edit-bitstream-page/edit-bitstream-page.component';
|
||||
import { BitstreamPageRoutingModule } from './bitstream-page-routing.module';
|
||||
import { BitstreamAuthorizationsComponent } from './bitstream-authorizations/bitstream-authorizations.component';
|
||||
|
||||
/**
|
||||
* This module handles all components that are necessary for Bitstream related pages
|
||||
@@ -14,6 +15,7 @@ import { BitstreamPageRoutingModule } from './bitstream-page-routing.module';
|
||||
BitstreamPageRoutingModule
|
||||
],
|
||||
declarations: [
|
||||
BitstreamAuthorizationsComponent,
|
||||
EditBitstreamPageComponent
|
||||
]
|
||||
})
|
||||
|
@@ -35,7 +35,7 @@ export class BitstreamPageResolver implements Resolve<RemoteData<Bitstream>> {
|
||||
*/
|
||||
get followLinks(): FollowLinkConfig<Bitstream>[] {
|
||||
return [
|
||||
followLink('bundle', undefined, true, true, true, followLink('item')),
|
||||
followLink('bundle', {}, followLink('item')),
|
||||
followLink('format')
|
||||
];
|
||||
}
|
||||
|
@@ -19,7 +19,11 @@
|
||||
[submitLabel]="'form.save'"
|
||||
(submitForm)="onSubmit()"
|
||||
(cancel)="onCancel()"
|
||||
(dfChange)="onChange($event)"></ds-form>
|
||||
(dfChange)="onChange($event)">
|
||||
<div additional class="container py-3">
|
||||
<a [routerLink]="['/bitstreams', bitstreamRD?.payload?.id, 'authorizations']">{{'bitstream.edit.authorizations.link' | translate}}</a>
|
||||
</div>
|
||||
</ds-form>
|
||||
</div>
|
||||
</div>
|
||||
<ds-error *ngIf="bitstreamRD?.hasFailed" message="{{'error.bitstream' | translate}}"></ds-error>
|
||||
|
@@ -18,12 +18,8 @@ import { hasValue } from '../../shared/empty.util';
|
||||
import { FormControl, FormGroup } from '@angular/forms';
|
||||
import { FileSizePipe } from '../../shared/utils/file-size-pipe';
|
||||
import { VarDirective } from '../../shared/utils/var.directive';
|
||||
import {
|
||||
createSuccessfulRemoteDataObject,
|
||||
createSuccessfulRemoteDataObject$
|
||||
} from '../../shared/remote-data.utils';
|
||||
import { RouterStub } from '../../shared/testing/router.stub';
|
||||
import { getEntityEditRoute, getItemEditRoute } from '../../+item-page/item-page-routing-paths';
|
||||
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||
import { getEntityEditRoute } from '../../+item-page/item-page-routing-paths';
|
||||
import { createPaginatedList } from '../../shared/testing/utils.test';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
|
||||
@@ -39,7 +35,6 @@ let bitstream: Bitstream;
|
||||
let selectedFormat: BitstreamFormat;
|
||||
let allFormats: BitstreamFormat[];
|
||||
let router: Router;
|
||||
let routerStub;
|
||||
|
||||
describe('EditBitstreamPageComponent', () => {
|
||||
let comp: EditBitstreamPageComponent;
|
||||
@@ -129,10 +124,6 @@ describe('EditBitstreamPageComponent', () => {
|
||||
findAll: createSuccessfulRemoteDataObject$(createPaginatedList(allFormats))
|
||||
});
|
||||
|
||||
const itemPageUrl = `fake-url/some-uuid`;
|
||||
routerStub = Object.assign(new RouterStub(), {
|
||||
url: `${itemPageUrl}`
|
||||
});
|
||||
TestBed.configureTestingModule({
|
||||
imports: [TranslateModule.forRoot(), RouterTestingModule],
|
||||
declarations: [EditBitstreamPageComponent, FileSizePipe, VarDirective],
|
||||
@@ -142,7 +133,6 @@ describe('EditBitstreamPageComponent', () => {
|
||||
{ provide: ActivatedRoute, useValue: { data: observableOf({ bitstream: createSuccessfulRemoteDataObject(bitstream) }), snapshot: { queryParams: {} } } },
|
||||
{ provide: BitstreamDataService, useValue: bitstreamService },
|
||||
{ provide: BitstreamFormatDataService, useValue: bitstreamFormatService },
|
||||
{ provide: Router, useValue: routerStub },
|
||||
ChangeDetectorRef
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA]
|
||||
@@ -154,7 +144,8 @@ describe('EditBitstreamPageComponent', () => {
|
||||
fixture = TestBed.createComponent(EditBitstreamPageComponent);
|
||||
comp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
router = (comp as any).router;
|
||||
router = TestBed.inject(Router);
|
||||
spyOn(router, 'navigate');
|
||||
});
|
||||
|
||||
describe('on startup', () => {
|
||||
@@ -241,14 +232,14 @@ describe('EditBitstreamPageComponent', () => {
|
||||
it('should redirect to the item edit page on the bitstreams tab with the itemId from the component', () => {
|
||||
comp.itemId = 'some-uuid1';
|
||||
comp.navigateToItemEditBitstreams();
|
||||
expect(routerStub.navigate).toHaveBeenCalledWith([getEntityEditRoute(null, 'some-uuid1'), 'bitstreams']);
|
||||
expect(router.navigate).toHaveBeenCalledWith([getEntityEditRoute(null, 'some-uuid1'), 'bitstreams']);
|
||||
});
|
||||
});
|
||||
describe('when navigateToItemEditBitstreams is called, and the component does not have an itemId', () => {
|
||||
it('should redirect to the item edit page on the bitstreams tab with the itemId from the bundle links ', () => {
|
||||
comp.itemId = undefined;
|
||||
comp.navigateToItemEditBitstreams();
|
||||
expect(routerStub.navigate).toHaveBeenCalledWith([getEntityEditRoute(null, 'some-uuid'), 'bitstreams']);
|
||||
expect(router.navigate).toHaveBeenCalledWith([getEntityEditRoute(null, 'some-uuid'), 'bitstreams']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -19,10 +19,10 @@ import { cloneDeep } from 'lodash';
|
||||
import { BitstreamDataService } from '../../core/data/bitstream-data.service';
|
||||
import {
|
||||
getAllSucceededRemoteDataPayload,
|
||||
getFirstSucceededRemoteDataPayload,
|
||||
getRemoteDataPayload,
|
||||
getFirstCompletedRemoteData,
|
||||
getFirstSucceededRemoteData,
|
||||
getFirstCompletedRemoteData
|
||||
getFirstSucceededRemoteDataPayload,
|
||||
getRemoteDataPayload
|
||||
} from '../../core/shared/operators';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { BitstreamFormatDataService } from '../../core/data/bitstream-format-data.service';
|
||||
@@ -131,15 +131,6 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
|
||||
rows: 10
|
||||
});
|
||||
|
||||
/**
|
||||
* The Dynamic Input Model for the file's embargo (disabled on this page)
|
||||
*/
|
||||
embargoModel = new DynamicInputModel({
|
||||
id: 'embargo',
|
||||
name: 'embargo',
|
||||
disabled: true
|
||||
});
|
||||
|
||||
/**
|
||||
* The Dynamic Input Model for the selected format
|
||||
*/
|
||||
@@ -159,7 +150,7 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
|
||||
/**
|
||||
* All input models in a simple array for easier iterations
|
||||
*/
|
||||
inputModels = [this.fileNameModel, this.primaryBitstreamModel, this.descriptionModel, this.embargoModel, this.selectedFormatModel, this.newFormatModel];
|
||||
inputModels = [this.fileNameModel, this.primaryBitstreamModel, this.descriptionModel, this.selectedFormatModel, this.newFormatModel];
|
||||
|
||||
/**
|
||||
* The dynamic form fields used for editing the information of a bitstream
|
||||
@@ -179,12 +170,6 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
|
||||
this.descriptionModel
|
||||
]
|
||||
}),
|
||||
new DynamicFormGroupModel({
|
||||
id: 'embargoContainer',
|
||||
group: [
|
||||
this.embargoModel
|
||||
]
|
||||
}),
|
||||
new DynamicFormGroupModel({
|
||||
id: 'formatContainer',
|
||||
group: [
|
||||
@@ -243,11 +228,6 @@ export class EditBitstreamPageComponent implements OnInit, OnDestroy {
|
||||
host: 'row'
|
||||
}
|
||||
},
|
||||
embargoContainer: {
|
||||
grid: {
|
||||
host: 'row'
|
||||
}
|
||||
},
|
||||
formatContainer: {
|
||||
grid: {
|
||||
host: 'row'
|
||||
|
@@ -1,6 +1,11 @@
|
||||
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, Subject } from 'rxjs';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest as observableCombineLatest,
|
||||
Observable,
|
||||
Subject
|
||||
} from 'rxjs';
|
||||
import { filter, map, mergeMap, startWith, switchMap, take } from 'rxjs/operators';
|
||||
import { PaginatedSearchOptions } from '../shared/search/paginated-search-options.model';
|
||||
import { SearchService } from '../core/shared/search/search.service';
|
||||
@@ -8,8 +13,6 @@ import { SortDirection, SortOptions } from '../core/cache/models/sort-options.mo
|
||||
import { CollectionDataService } from '../core/data/collection-data.service';
|
||||
import { PaginatedList } from '../core/data/paginated-list.model';
|
||||
import { RemoteData } from '../core/data/remote-data';
|
||||
|
||||
import { MetadataService } from '../core/metadata/metadata.service';
|
||||
import { Bitstream } from '../core/shared/bitstream.model';
|
||||
|
||||
import { Collection } from '../core/shared/collection.model';
|
||||
@@ -65,7 +68,6 @@ export class CollectionPageComponent implements OnInit {
|
||||
constructor(
|
||||
private collectionDataService: CollectionDataService,
|
||||
private searchService: SearchService,
|
||||
private metadata: MetadataService,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private authService: AuthService,
|
||||
@@ -122,10 +124,6 @@ export class CollectionPageComponent implements OnInit {
|
||||
getAllSucceededRemoteDataPayload(),
|
||||
map((collection) => getCollectionPageRoute(collection.id))
|
||||
);
|
||||
|
||||
this.route.queryParams.pipe(take(1)).subscribe((params) => {
|
||||
this.metadata.processRemoteData(this.collectionRD$);
|
||||
});
|
||||
}
|
||||
|
||||
isNotEmpty(object: any) {
|
||||
|
@@ -14,7 +14,7 @@ import { ResolvedAction } from '../core/resolving/resolver.actions';
|
||||
* Requesting them as embeds will limit the number of requests
|
||||
*/
|
||||
export const COLLECTION_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig<Collection>[] = [
|
||||
followLink('parentCommunity', undefined, true, true, true,
|
||||
followLink('parentCommunity', {},
|
||||
followLink('parentCommunity')
|
||||
),
|
||||
followLink('logo')
|
||||
|
@@ -6,11 +6,12 @@
|
||||
<p class="pb-2">{{ 'collection.delete.text' | translate:{ dso: dso.name } }}</p>
|
||||
<div class="form-group row">
|
||||
<div class="col text-right">
|
||||
<button class="btn btn-outline-secondary" (click)="onCancel(dso)">
|
||||
<button class="btn btn-outline-secondary" (click)="onCancel(dso)" [disabled]="(processing$ | async)">
|
||||
<i class="fas fa-times"></i> {{'collection.delete.cancel' | translate}}
|
||||
</button>
|
||||
<button class="btn btn-danger mr-2" (click)="onConfirm(dso)">
|
||||
<i class="fas fa-trash"></i> {{'collection.delete.confirm' | translate}}
|
||||
<button class="btn btn-danger mr-2" (click)="onConfirm(dso)" [disabled]="(processing$ | async)">
|
||||
<span *ngIf="processing$ | async"><i class='fas fa-circle-notch fa-spin'></i> {{'collection.delete.processing' | translate}}</span>
|
||||
<span *ngIf="!(processing$ | async)"><i class="fas fa-trash"></i> {{'collection.delete.confirm' | translate}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -6,11 +6,12 @@
|
||||
<p class="pb-2">{{ 'community.delete.text' | translate:{ dso: dso.name } }}</p>
|
||||
<div class="form-group row">
|
||||
<div class="col text-right">
|
||||
<button class="btn btn-outline-secondary" (click)="onCancel(dso)">
|
||||
<button class="btn btn-outline-secondary" (click)="onCancel(dso)" [disabled]="(processing$ | async)">
|
||||
<i class="fas fa-times"></i> {{'community.delete.cancel' | translate}}
|
||||
</button>
|
||||
<button class="btn btn-danger mr-2" (click)="onConfirm(dso)">
|
||||
<i class="fas fa-trash"></i> {{'community.delete.confirm' | translate}}
|
||||
<button class="btn btn-danger mr-2" (click)="onConfirm(dso)" [disabled]="(processing$ | async)">
|
||||
<span *ngIf="processing$ | async"><i class='fas fa-circle-notch fa-spin'></i> {{'community.delete.processing' | translate}}</span>
|
||||
<span *ngIf="!(processing$ | async)"><i class="fas fa-trash"></i> {{'community.delete.confirm' | translate}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -4,10 +4,9 @@ import { ActivatedRoute } from '@angular/router';
|
||||
import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs';
|
||||
import { catchError, filter, first, map, mergeMap, take } from 'rxjs/operators';
|
||||
|
||||
import { PaginatedList, buildPaginatedList } from '../../../core/data/paginated-list.model';
|
||||
import { buildPaginatedList, PaginatedList } from '../../../core/data/paginated-list.model';
|
||||
import {
|
||||
getFirstSucceededRemoteDataPayload,
|
||||
getFirstSucceededRemoteDataWithNotEmptyPayload
|
||||
getFirstSucceededRemoteDataPayload, getFirstSucceededRemoteDataWithNotEmptyPayload,
|
||||
} from '../../../core/shared/operators';
|
||||
import { Item } from '../../../core/shared/item.model';
|
||||
import { followLink } from '../../../shared/utils/follow-link-config.model';
|
||||
@@ -15,7 +14,6 @@ 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
|
||||
@@ -79,7 +77,7 @@ export class ItemAuthorizationsComponent implements OnInit, OnDestroy {
|
||||
getFirstSucceededRemoteDataWithNotEmptyPayload(),
|
||||
map((item: Item) => this.linkService.resolveLink(
|
||||
item,
|
||||
followLink('bundles', new FindListOptions(), true, true, true, followLink('bitstreams'))
|
||||
followLink('bundles', {}, followLink('bitstreams'))
|
||||
))
|
||||
) as Observable<Item>;
|
||||
|
||||
|
@@ -5,19 +5,16 @@
|
||||
<p>{{'item.edit.move.description' | translate}}</p>
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<ds-dso-input-suggestions #f id="search-form"
|
||||
[suggestions]="(collectionSearchResults | async)"
|
||||
[placeholder]="'item.edit.move.search.placeholder'| translate"
|
||||
[action]="getCurrentUrl()"
|
||||
[name]="'item-move'"
|
||||
[(ngModel)]="selectedCollectionName"
|
||||
(clickSuggestion)="onClick($event)"
|
||||
(typeSuggestion)="resetCollection($event)"
|
||||
(findSuggestions)="findSuggestions($event)"
|
||||
(click)="f.open()"
|
||||
ngDefaultControl>
|
||||
</ds-dso-input-suggestions>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">{{'dso-selector.placeholder' | translate: { type: 'collection' } }}</div>
|
||||
<div class="card-body">
|
||||
<ds-authorized-collection-selector [types]="COLLECTIONS"
|
||||
[currentDSOId]="selectedCollection ? selectedCollection.id : originalCollection.id"
|
||||
(onSelect)="selectDso($event)">
|
||||
</ds-authorized-collection-selector>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
@@ -33,16 +30,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button (click)="moveCollection()" class="btn btn-primary" [disabled]=!canSubmit>
|
||||
<span *ngIf="!processing"> {{'item.edit.move.move' | translate}}</span>
|
||||
<span *ngIf="processing"><i class='fas fa-circle-notch fa-spin'></i>
|
||||
{{'item.edit.move.processing' | translate}}
|
||||
</span>
|
||||
</button>
|
||||
<button [routerLink]="[(itemPageRoute$ | async), 'edit']"
|
||||
class="btn btn-outline-secondary">
|
||||
{{'item.edit.move.cancel' | translate}}
|
||||
</button>
|
||||
<div class="button-row bottom">
|
||||
<div class="float-right">
|
||||
<button [routerLink]="[(itemPageRoute$ | async), 'edit']" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left"></i> {{'item.edit.move.cancel' | translate}}
|
||||
</button>
|
||||
<button class="btn btn-primary mr-0" [disabled]="!canMove" (click)="moveToCollection()">
|
||||
<span *ngIf="!processing">
|
||||
<i class="fas fa-save"></i> {{'item.edit.move.save-button' | translate}}
|
||||
</span>
|
||||
<span *ngIf="processing">
|
||||
<i class="fas fa-circle-notch fa-spin"></i> {{'item.edit.move.processing' | translate}}
|
||||
</span>
|
||||
</button>
|
||||
<button class="btn btn-danger" [disabled]="!canSubmit" (click)="discard()">
|
||||
<i class="fas fa-times"></i> {{"item.edit.move.discard-button" | translate}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -21,6 +21,8 @@ import {
|
||||
createSuccessfulRemoteDataObject$
|
||||
} from '../../../shared/remote-data.utils';
|
||||
import { createPaginatedList } from '../../../shared/testing/utils.test';
|
||||
import { RequestService } from '../../../core/data/request.service';
|
||||
import { getMockRequestService } from '../../../shared/mocks/request.service.mock';
|
||||
|
||||
describe('ItemMoveComponent', () => {
|
||||
let comp: ItemMoveComponent;
|
||||
@@ -47,18 +49,25 @@ describe('ItemMoveComponent', () => {
|
||||
name: 'Test collection 2'
|
||||
});
|
||||
|
||||
const mockItemDataService = jasmine.createSpyObj({
|
||||
moveToCollection: createSuccessfulRemoteDataObject$(collection1)
|
||||
let itemDataService;
|
||||
|
||||
const mockItemDataServiceSuccess = jasmine.createSpyObj({
|
||||
moveToCollection: createSuccessfulRemoteDataObject$(collection1),
|
||||
findById: createSuccessfulRemoteDataObject$(mockItem),
|
||||
});
|
||||
|
||||
const mockItemDataServiceFail = jasmine.createSpyObj({
|
||||
moveToCollection: createFailedRemoteDataObject$('Internal server error', 500)
|
||||
moveToCollection: createFailedRemoteDataObject$('Internal server error', 500),
|
||||
findById: createSuccessfulRemoteDataObject$(mockItem),
|
||||
});
|
||||
|
||||
const routeStub = {
|
||||
data: observableOf({
|
||||
dso: createSuccessfulRemoteDataObject(Object.assign(new Item(), {
|
||||
id: 'item1'
|
||||
id: 'item1',
|
||||
owningCollection: createSuccessfulRemoteDataObject$(Object.assign(new Collection(), {
|
||||
id: 'originalOwningCollection',
|
||||
}))
|
||||
}))
|
||||
})
|
||||
};
|
||||
@@ -79,43 +88,40 @@ describe('ItemMoveComponent', () => {
|
||||
|
||||
const notificationsServiceStub = new NotificationsServiceStub();
|
||||
|
||||
const init = (mockItemDataService) => {
|
||||
itemDataService = mockItemDataService;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule],
|
||||
declarations: [ItemMoveComponent],
|
||||
providers: [
|
||||
{ provide: ActivatedRoute, useValue: routeStub },
|
||||
{ provide: Router, useValue: routerStub },
|
||||
{ provide: ItemDataService, useValue: mockItemDataService },
|
||||
{ provide: NotificationsService, useValue: notificationsServiceStub },
|
||||
{ provide: SearchService, useValue: mockSearchService },
|
||||
{ provide: RequestService, useValue: getMockRequestService() },
|
||||
], schemas: [
|
||||
CUSTOM_ELEMENTS_SCHEMA
|
||||
]
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(ItemMoveComponent);
|
||||
comp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
||||
describe('ItemMoveComponent success', () => {
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule],
|
||||
declarations: [ItemMoveComponent],
|
||||
providers: [
|
||||
{ provide: ActivatedRoute, useValue: routeStub },
|
||||
{ provide: Router, useValue: routerStub },
|
||||
{ provide: ItemDataService, useValue: mockItemDataService },
|
||||
{ provide: NotificationsService, useValue: notificationsServiceStub },
|
||||
{ provide: SearchService, useValue: mockSearchService },
|
||||
], schemas: [
|
||||
CUSTOM_ELEMENTS_SCHEMA
|
||||
]
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(ItemMoveComponent);
|
||||
comp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
init(mockItemDataServiceSuccess);
|
||||
});
|
||||
it('should load suggestions', () => {
|
||||
const expected = [
|
||||
collection1,
|
||||
collection2
|
||||
];
|
||||
|
||||
comp.collectionSearchResults.subscribe((value) => {
|
||||
expect(value).toEqual(expected);
|
||||
}
|
||||
);
|
||||
});
|
||||
it('should get current url ', () => {
|
||||
expect(comp.getCurrentUrl()).toEqual('fake-url/fake-id/edit');
|
||||
});
|
||||
it('should on click select the correct collection name and id', () => {
|
||||
it('should select the correct collection name and id on click', () => {
|
||||
const data = collection1;
|
||||
|
||||
comp.onClick(data);
|
||||
comp.selectDso(data);
|
||||
|
||||
expect(comp.selectedCollectionName).toEqual('Test collection 1');
|
||||
expect(comp.selectedCollection).toEqual(collection1);
|
||||
@@ -128,12 +134,12 @@ describe('ItemMoveComponent', () => {
|
||||
});
|
||||
comp.selectedCollectionName = 'selected-collection-id';
|
||||
comp.selectedCollection = collection1;
|
||||
comp.moveCollection();
|
||||
comp.moveToCollection();
|
||||
|
||||
expect(mockItemDataService.moveToCollection).toHaveBeenCalledWith('item-id', collection1);
|
||||
expect(itemDataService.moveToCollection).toHaveBeenCalledWith('item-id', collection1);
|
||||
});
|
||||
it('should call notificationsService success message on success', () => {
|
||||
comp.moveCollection();
|
||||
comp.moveToCollection();
|
||||
|
||||
expect(notificationsServiceStub.success).toHaveBeenCalled();
|
||||
});
|
||||
@@ -142,26 +148,11 @@ describe('ItemMoveComponent', () => {
|
||||
|
||||
describe('ItemMoveComponent fail', () => {
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule],
|
||||
declarations: [ItemMoveComponent],
|
||||
providers: [
|
||||
{ provide: ActivatedRoute, useValue: routeStub },
|
||||
{ provide: Router, useValue: routerStub },
|
||||
{ provide: ItemDataService, useValue: mockItemDataServiceFail },
|
||||
{ provide: NotificationsService, useValue: notificationsServiceStub },
|
||||
{ provide: SearchService, useValue: mockSearchService },
|
||||
], schemas: [
|
||||
CUSTOM_ELEMENTS_SCHEMA
|
||||
]
|
||||
}).compileComponents();
|
||||
fixture = TestBed.createComponent(ItemMoveComponent);
|
||||
comp = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
init(mockItemDataServiceFail);
|
||||
});
|
||||
|
||||
it('should call notificationsService error message on fail', () => {
|
||||
comp.moveCollection();
|
||||
comp.moveToCollection();
|
||||
|
||||
expect(notificationsServiceStub.error).toHaveBeenCalled();
|
||||
});
|
||||
|
@@ -1,25 +1,21 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { first, map } from 'rxjs/operators';
|
||||
import { map, switchMap } from 'rxjs/operators';
|
||||
import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
|
||||
import { PaginatedList } from '../../../core/data/paginated-list.model';
|
||||
import { Item } from '../../../core/shared/item.model';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
getFirstSucceededRemoteData,
|
||||
getFirstCompletedRemoteData, getAllSucceededRemoteDataPayload
|
||||
getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData, getFirstSucceededRemoteData, getRemoteDataPayload,
|
||||
} from '../../../core/shared/operators';
|
||||
import { ItemDataService } from '../../../core/data/item-data.service';
|
||||
import { Observable, of as observableOf } from 'rxjs';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Collection } from '../../../core/shared/collection.model';
|
||||
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
|
||||
import { SearchService } from '../../../core/shared/search/search.service';
|
||||
import { PaginatedSearchOptions } from '../../../shared/search/paginated-search-options.model';
|
||||
import { SearchResult } from '../../../shared/search/search-result.model';
|
||||
import { getItemEditRoute, getItemPageRoute } from '../../item-page-routing-paths';
|
||||
import { followLink } from '../../../shared/utils/follow-link-config.model';
|
||||
import { RequestService } from '../../../core/data/request.service';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-item-move',
|
||||
@@ -38,7 +34,8 @@ export class ItemMoveComponent implements OnInit {
|
||||
|
||||
inheritPolicies = false;
|
||||
itemRD$: Observable<RemoteData<Item>>;
|
||||
collectionSearchResults: Observable<any[]> = observableOf([]);
|
||||
originalCollection: Collection;
|
||||
|
||||
selectedCollectionName: string;
|
||||
selectedCollection: Collection;
|
||||
canSubmit = false;
|
||||
@@ -46,23 +43,26 @@ export class ItemMoveComponent implements OnInit {
|
||||
item: Item;
|
||||
processing = false;
|
||||
|
||||
pagination = new PaginationComponentOptions();
|
||||
|
||||
/**
|
||||
* Route to the item's page
|
||||
*/
|
||||
itemPageRoute$: Observable<string>;
|
||||
|
||||
COLLECTIONS = [DSpaceObjectType.COLLECTION];
|
||||
|
||||
constructor(private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private notificationsService: NotificationsService,
|
||||
private itemDataService: ItemDataService,
|
||||
private searchService: SearchService,
|
||||
private translateService: TranslateService) {
|
||||
}
|
||||
private translateService: TranslateService,
|
||||
private requestService: RequestService,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.itemRD$ = this.route.data.pipe(map((data) => data.dso), getFirstSucceededRemoteData()) as Observable<RemoteData<Item>>;
|
||||
this.itemRD$ = this.route.data.pipe(
|
||||
map((data) => data.dso), getFirstSucceededRemoteData()
|
||||
) as Observable<RemoteData<Item>>;
|
||||
this.itemPageRoute$ = this.itemRD$.pipe(
|
||||
getAllSucceededRemoteDataPayload(),
|
||||
map((item) => getItemPageRoute(item))
|
||||
@@ -71,43 +71,22 @@ export class ItemMoveComponent implements OnInit {
|
||||
this.item = rd.payload;
|
||||
}
|
||||
);
|
||||
this.pagination.pageSize = 5;
|
||||
this.loadSuggestions('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find suggestions based on entered query
|
||||
* @param query - Search query
|
||||
*/
|
||||
findSuggestions(query): void {
|
||||
this.loadSuggestions(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all available collections to move the item to.
|
||||
* TODO: When the API support it, only fetch collections where user has ADD rights to.
|
||||
*/
|
||||
loadSuggestions(query): void {
|
||||
this.collectionSearchResults = this.searchService.search(new PaginatedSearchOptions({
|
||||
pagination: this.pagination,
|
||||
dsoTypes: [DSpaceObjectType.COLLECTION],
|
||||
query: query
|
||||
})).pipe(
|
||||
first(),
|
||||
map((rd: RemoteData<PaginatedList<SearchResult<DSpaceObject>>>) => {
|
||||
return rd.payload.page.map((searchResult) => {
|
||||
return searchResult.indexableObject;
|
||||
});
|
||||
}) ,
|
||||
);
|
||||
|
||||
this.itemRD$.pipe(
|
||||
getFirstSucceededRemoteData(),
|
||||
getRemoteDataPayload(),
|
||||
switchMap((item) => item.owningCollection),
|
||||
getFirstSucceededRemoteData(),
|
||||
getRemoteDataPayload(),
|
||||
).subscribe((collection) => {
|
||||
this.originalCollection = collection;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the collection name and id based on the selected value
|
||||
* @param data - obtained from the ds-input-suggestions component
|
||||
*/
|
||||
onClick(data: any): void {
|
||||
selectDso(data: any): void {
|
||||
this.selectedCollection = data;
|
||||
this.selectedCollectionName = data.name;
|
||||
this.canSubmit = true;
|
||||
@@ -123,26 +102,41 @@ export class ItemMoveComponent implements OnInit {
|
||||
/**
|
||||
* Moves the item to a new collection based on the selected collection
|
||||
*/
|
||||
moveCollection() {
|
||||
moveToCollection() {
|
||||
this.processing = true;
|
||||
this.itemDataService.moveToCollection(this.item.id, this.selectedCollection).pipe(getFirstCompletedRemoteData()).subscribe(
|
||||
(response: RemoteData<Collection>) => {
|
||||
this.router.navigate([getItemEditRoute(this.item)]);
|
||||
if (response.hasSucceeded) {
|
||||
this.notificationsService.success(this.translateService.get('item.edit.move.success'));
|
||||
} else {
|
||||
this.notificationsService.error(this.translateService.get('item.edit.move.error'));
|
||||
}
|
||||
this.processing = false;
|
||||
const move$ = this.itemDataService.moveToCollection(this.item.id, this.selectedCollection)
|
||||
.pipe(getFirstCompletedRemoteData());
|
||||
|
||||
move$.subscribe((response: RemoteData<any>) => {
|
||||
if (response.hasSucceeded) {
|
||||
this.notificationsService.success(this.translateService.get('item.edit.move.success'));
|
||||
} else {
|
||||
this.notificationsService.error(this.translateService.get('item.edit.move.error'));
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
move$.pipe(
|
||||
switchMap(() => this.requestService.setStaleByHrefSubstring(this.item.id)),
|
||||
switchMap(() =>
|
||||
this.itemDataService.findById(
|
||||
this.item.id,
|
||||
false,
|
||||
true,
|
||||
followLink('owningCollection')
|
||||
)),
|
||||
getFirstCompletedRemoteData()
|
||||
).subscribe(() => {
|
||||
this.processing = false;
|
||||
this.router.navigate([getItemEditRoute(this.item)]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the can submit when the user changes the content of the input field
|
||||
* @param data
|
||||
*/
|
||||
resetCollection(data: any) {
|
||||
discard(): void {
|
||||
this.selectedCollection = null;
|
||||
this.canSubmit = false;
|
||||
}
|
||||
|
||||
get canMove(): boolean {
|
||||
return this.canSubmit && this.selectedCollection?.id !== this.originalCollection.id;
|
||||
}
|
||||
}
|
||||
|
@@ -6,21 +6,30 @@
|
||||
</button>
|
||||
</h5>
|
||||
<ng-container *ngVar="updates$ | async as updates">
|
||||
<ng-container *ngIf="updates">
|
||||
<ng-container *ngIf="updates && !(loading$ | async)">
|
||||
<ng-container *ngVar="updates | dsObjectValues as updateValues">
|
||||
<ds-edit-relationship *ngFor="let updateValue of updateValues; trackBy: trackUpdate"
|
||||
class="relationship-row d-block alert"
|
||||
[fieldUpdate]="updateValue || {}"
|
||||
[url]="url"
|
||||
[editItem]="item"
|
||||
[ngClass]="{
|
||||
<ds-pagination
|
||||
[paginationOptions]="paginationConfig"
|
||||
[pageInfoState]="(relationshipsRd$ | async)?.payload?.pageInfo"
|
||||
[collectionSize]="(relationshipsRd$ | async)?.payload?.totalElements + (this.nbAddedFields$ | async)"
|
||||
[hideGear]="true"
|
||||
[hidePagerWhenSinglePage]="true">
|
||||
<div class="my-2">
|
||||
<ds-edit-relationship *ngFor="let updateValue of updateValues; trackBy: trackUpdate"
|
||||
class="relationship-row d-block alert"
|
||||
[fieldUpdate]="updateValue || {}"
|
||||
[url]="url"
|
||||
[editItem]="item"
|
||||
[ngClass]="{
|
||||
'alert-success': updateValue.changeType === 1,
|
||||
'alert-warning': updateValue.changeType === 0,
|
||||
'alert-danger': updateValue.changeType === 2
|
||||
}">
|
||||
</ds-edit-relationship>
|
||||
<div *ngIf="updateValues.length === 0">{{"item.edit.relationships.no-relationships" | translate}}</div>
|
||||
</ds-edit-relationship>
|
||||
</div>
|
||||
</ds-pagination>
|
||||
<div *ngIf="updateValues.length === 0">{{"item.edit.relationships.no-relationships" | translate}}</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<ds-loading *ngIf="!updates"></ds-loading>
|
||||
<ds-loading *ngIf="loading$ | async"></ds-loading>
|
||||
</ng-container>
|
||||
|
@@ -16,6 +16,12 @@ import { SharedModule } from '../../../../shared/shared.module';
|
||||
import { EditRelationshipListComponent } from './edit-relationship-list.component';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../../../shared/remote-data.utils';
|
||||
import { createPaginatedList } from '../../../../shared/testing/utils.test';
|
||||
import { PaginationService } from '../../../../core/pagination/pagination.service';
|
||||
import { PaginationServiceStub } from '../../../../shared/testing/pagination-service.stub';
|
||||
import { HostWindowService } from '../../../../shared/host-window.service';
|
||||
import { HostWindowServiceStub } from '../../../../shared/testing/host-window-service.stub';
|
||||
import { PaginationComponent } from '../../../../shared/pagination/pagination.component';
|
||||
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
|
||||
|
||||
let comp: EditRelationshipListComponent;
|
||||
let fixture: ComponentFixture<EditRelationshipListComponent>;
|
||||
@@ -25,6 +31,8 @@ let linkService;
|
||||
let objectUpdatesService;
|
||||
let relationshipService;
|
||||
let selectableListService;
|
||||
let paginationService;
|
||||
let hostWindowService;
|
||||
|
||||
const url = 'http://test-url.com/test-url';
|
||||
|
||||
@@ -37,9 +45,21 @@ let fieldUpdate1;
|
||||
let fieldUpdate2;
|
||||
let relationships;
|
||||
let relationshipType;
|
||||
let paginationOptions;
|
||||
|
||||
describe('EditRelationshipListComponent', () => {
|
||||
|
||||
const resetComponent = () => {
|
||||
fixture = TestBed.createComponent(EditRelationshipListComponent);
|
||||
comp = fixture.componentInstance;
|
||||
de = fixture.debugElement;
|
||||
comp.item = item;
|
||||
comp.itemType = entityType;
|
||||
comp.url = url;
|
||||
comp.relationshipType = relationshipType;
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
|
||||
entityType = Object.assign(new ItemType(), {
|
||||
@@ -63,6 +83,12 @@ describe('EditRelationshipListComponent', () => {
|
||||
rightwardType: 'isPublicationOfAuthor',
|
||||
});
|
||||
|
||||
paginationOptions = Object.assign(new PaginationComponentOptions(), {
|
||||
id: `er${relationshipType.id}`,
|
||||
pageSize: 5,
|
||||
currentPage: 1,
|
||||
});
|
||||
|
||||
author1 = Object.assign(new Item(), {
|
||||
id: 'author1',
|
||||
uuid: 'author1'
|
||||
@@ -141,6 +167,10 @@ describe('EditRelationshipListComponent', () => {
|
||||
resolveLinks: () => null,
|
||||
};
|
||||
|
||||
paginationService = new PaginationServiceStub(paginationOptions);
|
||||
|
||||
hostWindowService = new HostWindowServiceStub(1200);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [SharedModule, TranslateModule.forRoot()],
|
||||
declarations: [EditRelationshipListComponent],
|
||||
@@ -149,22 +179,15 @@ describe('EditRelationshipListComponent', () => {
|
||||
{ provide: RelationshipService, useValue: relationshipService },
|
||||
{ provide: SelectableListService, useValue: selectableListService },
|
||||
{ provide: LinkService, useValue: linkService },
|
||||
{ provide: PaginationService, useValue: paginationService },
|
||||
{ provide: HostWindowService, useValue: hostWindowService },
|
||||
], schemas: [
|
||||
NO_ERRORS_SCHEMA
|
||||
]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(EditRelationshipListComponent);
|
||||
comp = fixture.componentInstance;
|
||||
de = fixture.debugElement;
|
||||
comp.item = item;
|
||||
comp.itemType = entityType;
|
||||
comp.url = url;
|
||||
comp.relationshipType = relationshipType;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
resetComponent();
|
||||
}));
|
||||
|
||||
describe('changeType is REMOVE', () => {
|
||||
beforeEach(() => {
|
||||
@@ -176,4 +199,82 @@ describe('EditRelationshipListComponent', () => {
|
||||
expect(element.classList).toContain('alert-danger');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination component', () => {
|
||||
let paginationComp: PaginationComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
paginationComp = de.query(By.css('ds-pagination')).componentInstance;
|
||||
});
|
||||
|
||||
it('should receive the correct pagination config', () => {
|
||||
expect(paginationComp.paginationOptions).toEqual(paginationOptions);
|
||||
});
|
||||
|
||||
it('should receive correct collection size', () => {
|
||||
expect(paginationComp.collectionSize).toEqual(relationships.length);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('relationshipService.getItemRelationshipsByLabel', () => {
|
||||
it('should receive the correct pagination info', () => {
|
||||
expect(relationshipService.getItemRelationshipsByLabel).toHaveBeenCalledTimes(1);
|
||||
|
||||
const callArgs = relationshipService.getItemRelationshipsByLabel.calls.mostRecent().args;
|
||||
const findListOptions = callArgs[2];
|
||||
|
||||
expect(findListOptions.elementsPerPage).toEqual(paginationOptions.pageSize);
|
||||
expect(findListOptions.currentPage).toEqual(paginationOptions.currentPage);
|
||||
});
|
||||
|
||||
describe('when the publication is on the left side of the relationship', () => {
|
||||
beforeEach(() => {
|
||||
relationshipType = Object.assign(new RelationshipType(), {
|
||||
id: '1',
|
||||
uuid: '1',
|
||||
leftType: createSuccessfulRemoteDataObject$(entityType), // publication
|
||||
rightType: createSuccessfulRemoteDataObject$(relatedEntityType), // author
|
||||
leftwardType: 'isAuthorOfPublication',
|
||||
rightwardType: 'isPublicationOfAuthor',
|
||||
});
|
||||
relationshipService.getItemRelationshipsByLabel.calls.reset();
|
||||
resetComponent();
|
||||
});
|
||||
|
||||
it('should fetch isAuthorOfPublication', () => {
|
||||
expect(relationshipService.getItemRelationshipsByLabel).toHaveBeenCalledTimes(1);
|
||||
|
||||
const callArgs = relationshipService.getItemRelationshipsByLabel.calls.mostRecent().args;
|
||||
const label = callArgs[1];
|
||||
|
||||
expect(label).toEqual('isAuthorOfPublication');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the publication is on the right side of the relationship', () => {
|
||||
beforeEach(() => {
|
||||
relationshipType = Object.assign(new RelationshipType(), {
|
||||
id: '1',
|
||||
uuid: '1',
|
||||
leftType: createSuccessfulRemoteDataObject$(relatedEntityType), // author
|
||||
rightType: createSuccessfulRemoteDataObject$(entityType), // publication
|
||||
leftwardType: 'isPublicationOfAuthor',
|
||||
rightwardType: 'isAuthorOfPublication',
|
||||
});
|
||||
relationshipService.getItemRelationshipsByLabel.calls.reset();
|
||||
resetComponent();
|
||||
});
|
||||
|
||||
it('should fetch isAuthorOfPublication', () => {
|
||||
expect(relationshipService.getItemRelationshipsByLabel).toHaveBeenCalledTimes(1);
|
||||
|
||||
const callArgs = relationshipService.getItemRelationshipsByLabel.calls.mostRecent().args;
|
||||
const label = callArgs[1];
|
||||
|
||||
expect(label).toEqual('isAuthorOfPublication');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
@@ -1,9 +1,14 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { Component, Input, OnInit, OnDestroy } from '@angular/core';
|
||||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { LinkService } from '../../../../core/cache/builders/link.service';
|
||||
import { FieldChangeType } from '../../../../core/data/object-updates/object-updates.actions';
|
||||
import { ObjectUpdatesService } from '../../../../core/data/object-updates/object-updates.service';
|
||||
import { combineLatest as observableCombineLatest, Observable, of } from 'rxjs';
|
||||
import {
|
||||
combineLatest as observableCombineLatest,
|
||||
Observable,
|
||||
of as observableOf,
|
||||
from as observableFrom
|
||||
} from 'rxjs';
|
||||
import {
|
||||
FieldUpdate,
|
||||
FieldUpdates,
|
||||
@@ -11,14 +16,24 @@ import {
|
||||
} from '../../../../core/data/object-updates/object-updates.reducer';
|
||||
import { RelationshipService } from '../../../../core/data/relationship.service';
|
||||
import { Item } from '../../../../core/shared/item.model';
|
||||
import { defaultIfEmpty, map, mergeMap, switchMap, take, startWith } from 'rxjs/operators';
|
||||
import { hasValue, hasValueOperator } from '../../../../shared/empty.util';
|
||||
import {
|
||||
defaultIfEmpty,
|
||||
map,
|
||||
mergeMap,
|
||||
switchMap,
|
||||
take,
|
||||
startWith,
|
||||
toArray,
|
||||
tap
|
||||
} from 'rxjs/operators';
|
||||
import { hasValue, hasValueOperator, hasNoValue } from '../../../../shared/empty.util';
|
||||
import { Relationship } from '../../../../core/shared/item-relationships/relationship.model';
|
||||
import { RelationshipType } from '../../../../core/shared/item-relationships/relationship-type.model';
|
||||
import {
|
||||
getAllSucceededRemoteData,
|
||||
getRemoteDataPayload,
|
||||
getFirstSucceededRemoteData, getFirstSucceededRemoteDataPayload,
|
||||
getFirstSucceededRemoteData,
|
||||
getFirstSucceededRemoteDataPayload,
|
||||
getAllSucceededRemoteData,
|
||||
} from '../../../../core/shared/operators';
|
||||
import { ItemType } from '../../../../core/shared/item-relationships/item-type.model';
|
||||
import { DsDynamicLookupRelationModalComponent } from '../../../../shared/form/builder/ds-dynamic-form-ui/relation-lookup-modal/dynamic-lookup-relation-modal.component';
|
||||
@@ -30,6 +45,10 @@ import { followLink } from '../../../../shared/utils/follow-link-config.model';
|
||||
import { PaginatedList } from '../../../../core/data/paginated-list.model';
|
||||
import { RemoteData } from '../../../../core/data/remote-data';
|
||||
import { Collection } from '../../../../core/shared/collection.model';
|
||||
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
|
||||
import { Subscription } from 'rxjs/internal/Subscription';
|
||||
import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model';
|
||||
import { PaginationService } from '../../../../core/pagination/pagination.service';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-edit-relationship-list',
|
||||
@@ -40,7 +59,7 @@ import { Collection } from '../../../../core/shared/collection.model';
|
||||
* A component creating a list of editable relationships of a certain type
|
||||
* The relationships are rendered as a list of related items
|
||||
*/
|
||||
export class EditRelationshipListComponent implements OnInit {
|
||||
export class EditRelationshipListComponent implements OnInit, OnDestroy {
|
||||
|
||||
/**
|
||||
* The item to display related items for
|
||||
@@ -60,6 +79,17 @@ export class EditRelationshipListComponent implements OnInit {
|
||||
*/
|
||||
@Input() relationshipType: RelationshipType;
|
||||
|
||||
/**
|
||||
* Observable that emits the left and right item type of {@link relationshipType} simultaneously.
|
||||
*/
|
||||
private relationshipLeftAndRightType$: Observable<[ItemType, ItemType]>;
|
||||
|
||||
/**
|
||||
* Observable that emits true if {@link itemType} is on the left-hand side of {@link relationshipType},
|
||||
* false if it is on the right-hand side and undefined in the rare case that it is on neither side.
|
||||
*/
|
||||
private currentItemIsLeftItem$: Observable<boolean>;
|
||||
|
||||
private relatedEntityType$: Observable<ItemType>;
|
||||
|
||||
/**
|
||||
@@ -70,7 +100,38 @@ export class EditRelationshipListComponent implements OnInit {
|
||||
/**
|
||||
* The FieldUpdates for the relationships in question
|
||||
*/
|
||||
updates$: Observable<FieldUpdates>;
|
||||
updates$: BehaviorSubject<FieldUpdates> = new BehaviorSubject(undefined);
|
||||
|
||||
/**
|
||||
* The RemoteData for the relationships
|
||||
*/
|
||||
relationshipsRd$: BehaviorSubject<RemoteData<PaginatedList<Relationship>>> = new BehaviorSubject(undefined);
|
||||
|
||||
/**
|
||||
* Whether the current page is the last page
|
||||
*/
|
||||
isLastPage$: BehaviorSubject<boolean> = new BehaviorSubject(true);
|
||||
|
||||
/**
|
||||
* Whether we're loading
|
||||
*/
|
||||
loading$: BehaviorSubject<boolean> = new BehaviorSubject(true);
|
||||
|
||||
/**
|
||||
* The number of added fields that haven't been saved yet
|
||||
*/
|
||||
nbAddedFields$: BehaviorSubject<number> = new BehaviorSubject(0);
|
||||
|
||||
/**
|
||||
* Array to track all subscriptions and unsubscribe them onDestroy
|
||||
* @type {Array}
|
||||
*/
|
||||
private subs: Subscription[] = [];
|
||||
|
||||
/**
|
||||
* The pagination config
|
||||
*/
|
||||
paginationConfig: PaginationComponentOptions;
|
||||
|
||||
/**
|
||||
* A reference to the lookup window
|
||||
@@ -82,6 +143,7 @@ export class EditRelationshipListComponent implements OnInit {
|
||||
protected linkService: LinkService,
|
||||
protected relationshipService: RelationshipService,
|
||||
protected modalService: NgbModal,
|
||||
protected paginationService: PaginationService,
|
||||
protected selectableListService: SelectableListService,
|
||||
) {
|
||||
}
|
||||
@@ -172,6 +234,10 @@ export class EditRelationshipListComponent implements OnInit {
|
||||
this.objectUpdatesService.saveAddFieldUpdate(this.url, update);
|
||||
});
|
||||
}
|
||||
|
||||
this.loading$.next(true);
|
||||
// emit the last page again to trigger a fieldupdates refresh
|
||||
this.relationshipsRd$.next(this.relationshipsRd$.getValue());
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -186,6 +252,10 @@ export class EditRelationshipListComponent implements OnInit {
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
this.loading$.next(true);
|
||||
// emit the last page again to trigger a fieldupdates refresh
|
||||
this.relationshipsRd$.next(this.relationshipsRd$.getValue());
|
||||
};
|
||||
this.relatedEntityType$
|
||||
.pipe(take(1))
|
||||
@@ -212,10 +282,10 @@ export class EditRelationshipListComponent implements OnInit {
|
||||
if (field.relationship) {
|
||||
return this.getRelatedItem(field.relationship);
|
||||
} else {
|
||||
return of(field.relatedItem);
|
||||
return observableOf(field.relatedItem);
|
||||
}
|
||||
})
|
||||
) : of([])
|
||||
) : observableOf([])
|
||||
),
|
||||
take(1),
|
||||
map((items) => items.map((item) => {
|
||||
@@ -267,18 +337,19 @@ export class EditRelationshipListComponent implements OnInit {
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// store the left and right type of the relationship in a single observable
|
||||
this.relationshipLeftAndRightType$ = observableCombineLatest([
|
||||
this.relationshipType.leftType,
|
||||
this.relationshipType.rightType,
|
||||
].map((type) => type.pipe(
|
||||
getFirstSucceededRemoteData(),
|
||||
getRemoteDataPayload(),
|
||||
))) as Observable<[ItemType, ItemType]>;
|
||||
|
||||
this.relatedEntityType$ =
|
||||
observableCombineLatest([
|
||||
this.relationshipType.leftType,
|
||||
this.relationshipType.rightType,
|
||||
].map((type) => type.pipe(
|
||||
getFirstSucceededRemoteData(),
|
||||
getRemoteDataPayload(),
|
||||
))).pipe(
|
||||
map((relatedTypes: ItemType[]) => relatedTypes.find((relatedType) => relatedType.uuid !== this.itemType.uuid)),
|
||||
hasValueOperator()
|
||||
);
|
||||
this.relatedEntityType$ = this.relationshipLeftAndRightType$.pipe(
|
||||
map((relatedTypes: ItemType[]) => relatedTypes.find((relatedType) => relatedType.uuid !== this.itemType.uuid)),
|
||||
hasValueOperator()
|
||||
);
|
||||
|
||||
this.relatedEntityType$.pipe(
|
||||
take(1)
|
||||
@@ -286,65 +357,142 @@ export class EditRelationshipListComponent implements OnInit {
|
||||
(relatedEntityType) => this.listId = `edit-relationship-${this.itemType.id}-${relatedEntityType.id}`
|
||||
);
|
||||
|
||||
this.updates$ = this.getItemRelationships().pipe(
|
||||
switchMap((relationships) =>
|
||||
observableCombineLatest(
|
||||
relationships.map((relationship) => this.relationshipService.isLeftItem(relationship, this.item))
|
||||
).pipe(
|
||||
defaultIfEmpty([]),
|
||||
map((isLeftItemArray) => isLeftItemArray.map((isLeftItem, index) => {
|
||||
const relationship = relationships[index];
|
||||
const nameVariant = isLeftItem ? relationship.rightwardValue : relationship.leftwardValue;
|
||||
this.currentItemIsLeftItem$ = this.relationshipLeftAndRightType$.pipe(
|
||||
map(([leftType, rightType]: [ItemType, ItemType]) => {
|
||||
if (leftType.id === this.itemType.id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (rightType.id === this.itemType.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// should never happen...
|
||||
console.warn(`The item ${this.item.uuid} is not on the right or the left side of relationship type ${this.relationshipType.uuid}`);
|
||||
return undefined;
|
||||
})
|
||||
);
|
||||
|
||||
// initialize the pagination options
|
||||
this.paginationConfig = new PaginationComponentOptions();
|
||||
this.paginationConfig.id = `er${this.relationshipType.id}`;
|
||||
this.paginationConfig.pageSize = 5;
|
||||
this.paginationConfig.currentPage = 1;
|
||||
|
||||
// get the pagination params from the route
|
||||
const currentPagination$ = this.paginationService.getCurrentPagination(
|
||||
this.paginationConfig.id,
|
||||
this.paginationConfig
|
||||
).pipe(
|
||||
tap(() => this.loading$.next(true))
|
||||
);
|
||||
|
||||
this.subs.push(
|
||||
observableCombineLatest([
|
||||
currentPagination$,
|
||||
this.currentItemIsLeftItem$,
|
||||
]).pipe(
|
||||
switchMap(([currentPagination, currentItemIsLeftItem]: [PaginationComponentOptions, boolean]) =>
|
||||
// get the relationships for the current item, relationshiptype and page
|
||||
this.relationshipService.getItemRelationshipsByLabel(
|
||||
this.item,
|
||||
currentItemIsLeftItem ? this.relationshipType.leftwardType : this.relationshipType.rightwardType,
|
||||
{
|
||||
elementsPerPage: currentPagination.pageSize,
|
||||
currentPage: currentPagination.currentPage,
|
||||
},
|
||||
false,
|
||||
true,
|
||||
followLink('leftItem'),
|
||||
followLink('rightItem'),
|
||||
)),
|
||||
).subscribe((rd: RemoteData<PaginatedList<Relationship>>) => {
|
||||
this.relationshipsRd$.next(rd);
|
||||
})
|
||||
);
|
||||
|
||||
// keep isLastPage$ up to date based on relationshipsRd$
|
||||
this.subs.push(this.relationshipsRd$.pipe(
|
||||
hasValueOperator(),
|
||||
getAllSucceededRemoteData()
|
||||
).subscribe((rd: RemoteData<PaginatedList<Relationship>>) => {
|
||||
this.isLastPage$.next(hasNoValue(rd.payload._links.next));
|
||||
}));
|
||||
|
||||
this.subs.push(this.relationshipsRd$.pipe(
|
||||
hasValueOperator(),
|
||||
getAllSucceededRemoteData(),
|
||||
switchMap((rd: RemoteData<PaginatedList<Relationship>>) =>
|
||||
// emit each relationship in the page separately
|
||||
observableFrom(rd.payload.page).pipe(
|
||||
mergeMap((relationship: Relationship) =>
|
||||
// check for each relationship whether it's the left item
|
||||
this.relationshipService.isLeftItem(relationship, this.item).pipe(
|
||||
// emit an array containing both the relationship and whether it's the left item,
|
||||
// as we'll need both
|
||||
map((isLeftItem: boolean) => [relationship, isLeftItem])
|
||||
)
|
||||
),
|
||||
map(([relationship, isLeftItem]: [Relationship, boolean]) => {
|
||||
// turn it into a RelationshipIdentifiable, an
|
||||
const nameVariant =
|
||||
isLeftItem ? relationship.rightwardValue : relationship.leftwardValue;
|
||||
return {
|
||||
uuid: relationship.id,
|
||||
type: this.relationshipType,
|
||||
relationship,
|
||||
nameVariant,
|
||||
} as RelationshipIdentifiable;
|
||||
})),
|
||||
)),
|
||||
switchMap((initialFields) => this.objectUpdatesService.getFieldUpdates(this.url, initialFields).pipe(
|
||||
map((fieldUpdates) => {
|
||||
const fieldUpdatesFiltered: FieldUpdates = {};
|
||||
Object.keys(fieldUpdates).forEach((uuid) => {
|
||||
if (hasValue(fieldUpdates[uuid])) {
|
||||
const field = fieldUpdates[uuid].field;
|
||||
if ((field as RelationshipIdentifiable).type.id === this.relationshipType.id) {
|
||||
fieldUpdatesFiltered[uuid] = fieldUpdates[uuid];
|
||||
}
|
||||
}
|
||||
});
|
||||
return fieldUpdatesFiltered;
|
||||
}),
|
||||
}),
|
||||
// wait until all relationships have been processed, and emit them all as a single array
|
||||
toArray(),
|
||||
// if the pipe above completes without emitting anything, emit an empty array instead
|
||||
defaultIfEmpty([])
|
||||
)),
|
||||
switchMap((nextFields: RelationshipIdentifiable[]) => {
|
||||
// Get a list that contains the unsaved changes for the page, as well as the page of
|
||||
// RelationshipIdentifiables, as a single list of FieldUpdates
|
||||
return this.objectUpdatesService.getFieldUpdates(this.url, nextFields).pipe(
|
||||
map((fieldUpdates: FieldUpdates) => {
|
||||
const fieldUpdatesFiltered: FieldUpdates = {};
|
||||
this.nbAddedFields$.next(0);
|
||||
// iterate over the fieldupdates and filter out the ones that pertain to this
|
||||
// relationshiptype
|
||||
Object.keys(fieldUpdates).forEach((uuid) => {
|
||||
if (hasValue(fieldUpdates[uuid])) {
|
||||
const field = fieldUpdates[uuid].field as RelationshipIdentifiable;
|
||||
// only include fieldupdates regarding this RelationshipType
|
||||
if (field.type.id === this.relationshipType.id) {
|
||||
// if it's a newly added relationship
|
||||
if (fieldUpdates[uuid].changeType === FieldChangeType.ADD) {
|
||||
// increase the counter that tracks new relationships
|
||||
this.nbAddedFields$.next(this.nbAddedFields$.getValue() + 1);
|
||||
if (this.isLastPage$.getValue() === true) {
|
||||
// only include newly added relationships to the output if we're on the last
|
||||
// page
|
||||
fieldUpdatesFiltered[uuid] = fieldUpdates[uuid];
|
||||
}
|
||||
} else {
|
||||
// include all others
|
||||
fieldUpdatesFiltered[uuid] = fieldUpdates[uuid];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return fieldUpdatesFiltered;
|
||||
}),
|
||||
);
|
||||
}),
|
||||
startWith({}),
|
||||
);
|
||||
).subscribe((updates: FieldUpdates) => {
|
||||
this.loading$.next(false);
|
||||
this.updates$.next(updates);
|
||||
}));
|
||||
}
|
||||
|
||||
private getItemRelationships() {
|
||||
this.linkService.resolveLink(this.item,
|
||||
followLink('relationships', undefined, true, true, true,
|
||||
followLink('relationshipType'),
|
||||
followLink('leftItem'),
|
||||
followLink('rightItem'),
|
||||
));
|
||||
return this.item.relationships.pipe(
|
||||
getAllSucceededRemoteData(),
|
||||
map((relationships: RemoteData<PaginatedList<Relationship>>) => relationships.payload.page.filter((relationship: Relationship) => hasValue(relationship))),
|
||||
switchMap((itemRelationships: Relationship[]) =>
|
||||
observableCombineLatest(
|
||||
itemRelationships
|
||||
.map((relationship) => relationship.relationshipType.pipe(
|
||||
getFirstSucceededRemoteData(),
|
||||
getRemoteDataPayload(),
|
||||
))
|
||||
).pipe(
|
||||
defaultIfEmpty([]),
|
||||
map((relationshipTypes) => itemRelationships.filter(
|
||||
(relationship, index) => relationshipTypes[index].id === this.relationshipType.id)
|
||||
),
|
||||
)
|
||||
),
|
||||
);
|
||||
ngOnDestroy(): void {
|
||||
this.subs
|
||||
.filter((subscription) => hasValue(subscription))
|
||||
.forEach((subscription) => subscription.unsubscribe());
|
||||
}
|
||||
}
|
||||
|
@@ -227,7 +227,6 @@ export class ItemRelationshipsComponent extends AbstractItemUpdateComponent {
|
||||
* Sends all initial values of this item to the object updates service
|
||||
*/
|
||||
public initializeOriginalFields() {
|
||||
console.log('init');
|
||||
return this.relationshipService.getRelatedItems(this.item).pipe(
|
||||
take(1),
|
||||
).subscribe((items: Item[]) => {
|
||||
|
@@ -1,4 +1,4 @@
|
||||
<div class="simple-view-element" [class.d-none]="content.textContent.trim().length === 0 && hasNoValue(content.querySelector('img'))">
|
||||
<div class="simple-view-element" [class.d-none]="hideIfNoTextContent && content.textContent.trim().length === 0">
|
||||
<h5 class="simple-view-element-header" *ngIf="label">{{ label }}</h5>
|
||||
<div #content class="simple-view-element-body">
|
||||
<ng-content></ng-content>
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { MetadataFieldWrapperComponent } from './metadata-field-wrapper.component';
|
||||
@@ -6,35 +6,34 @@ import { MetadataFieldWrapperComponent } from './metadata-field-wrapper.componen
|
||||
/* tslint:disable:max-classes-per-file */
|
||||
@Component({
|
||||
selector: 'ds-component-without-content',
|
||||
template: '<ds-metadata-field-wrapper [label]="\'test label\'">\n' +
|
||||
template: '<ds-metadata-field-wrapper [hideIfNoTextContent]="hideIfNoTextContent" [label]="\'test label\'">\n' +
|
||||
'</ds-metadata-field-wrapper>'
|
||||
})
|
||||
class NoContentComponent {}
|
||||
class NoContentComponent {
|
||||
public hideIfNoTextContent = true;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'ds-component-with-empty-spans',
|
||||
template: '<ds-metadata-field-wrapper [label]="\'test label\'">\n' +
|
||||
template: '<ds-metadata-field-wrapper [hideIfNoTextContent]="hideIfNoTextContent" [label]="\'test label\'">\n' +
|
||||
' <span></span>\n' +
|
||||
' <span></span>\n' +
|
||||
'</ds-metadata-field-wrapper>'
|
||||
})
|
||||
class SpanContentComponent {}
|
||||
class SpanContentComponent {
|
||||
@Input() hideIfNoTextContent = true;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'ds-component-with-text',
|
||||
template: '<ds-metadata-field-wrapper [label]="\'test label\'">\n' +
|
||||
template: '<ds-metadata-field-wrapper [hideIfNoTextContent]="hideIfNoTextContent" [label]="\'test label\'">\n' +
|
||||
' <span>The quick brown fox jumps over the lazy dog</span>\n' +
|
||||
'</ds-metadata-field-wrapper>'
|
||||
})
|
||||
class TextContentComponent {}
|
||||
class TextContentComponent {
|
||||
@Input() hideIfNoTextContent = true;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'ds-component-with-image',
|
||||
template: '<ds-metadata-field-wrapper [label]="\'test label\'">\n' +
|
||||
' <img src="https://some/image.png" alt="an alt text">\n' +
|
||||
'</ds-metadata-field-wrapper>'
|
||||
})
|
||||
class ImgContentComponent {}
|
||||
/* tslint:enable:max-classes-per-file */
|
||||
|
||||
describe('MetadataFieldWrapperComponent', () => {
|
||||
@@ -43,7 +42,7 @@ describe('MetadataFieldWrapperComponent', () => {
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [MetadataFieldWrapperComponent, NoContentComponent, SpanContentComponent, TextContentComponent, ImgContentComponent]
|
||||
declarations: [MetadataFieldWrapperComponent, NoContentComponent, SpanContentComponent, TextContentComponent]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
@@ -58,38 +57,60 @@ describe('MetadataFieldWrapperComponent', () => {
|
||||
expect(component).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not show the component when there is no content', () => {
|
||||
const parentFixture = TestBed.createComponent(NoContentComponent);
|
||||
parentFixture.detectChanges();
|
||||
const parentNative = parentFixture.nativeElement;
|
||||
const nativeWrapper = parentNative.querySelector(wrapperSelector);
|
||||
expect(nativeWrapper.classList.contains('d-none')).toBe(true);
|
||||
describe('with hideIfNoTextContent=true', () => {
|
||||
it('should not show the component when there is no content', () => {
|
||||
const parentFixture = TestBed.createComponent(NoContentComponent);
|
||||
parentFixture.detectChanges();
|
||||
const parentNative = parentFixture.nativeElement;
|
||||
const nativeWrapper = parentNative.querySelector(wrapperSelector);
|
||||
expect(nativeWrapper.classList.contains('d-none')).toBe(true);
|
||||
});
|
||||
|
||||
it('should not show the component when there is no text content', () => {
|
||||
const parentFixture = TestBed.createComponent(SpanContentComponent);
|
||||
parentFixture.detectChanges();
|
||||
const parentNative = parentFixture.nativeElement;
|
||||
const nativeWrapper = parentNative.querySelector(wrapperSelector);
|
||||
expect(nativeWrapper.classList.contains('d-none')).toBe(true);
|
||||
});
|
||||
|
||||
it('should show the component when there is text content', () => {
|
||||
const parentFixture = TestBed.createComponent(TextContentComponent);
|
||||
parentFixture.detectChanges();
|
||||
const parentNative = parentFixture.nativeElement;
|
||||
const nativeWrapper = parentNative.querySelector(wrapperSelector);
|
||||
parentFixture.detectChanges();
|
||||
expect(nativeWrapper.classList.contains('d-none')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not show the component when there is DOM content but not text or an image', () => {
|
||||
const parentFixture = TestBed.createComponent(SpanContentComponent);
|
||||
parentFixture.detectChanges();
|
||||
const parentNative = parentFixture.nativeElement;
|
||||
const nativeWrapper = parentNative.querySelector(wrapperSelector);
|
||||
expect(nativeWrapper.classList.contains('d-none')).toBe(true);
|
||||
});
|
||||
describe('with hideIfNoTextContent=false', () => {
|
||||
it('should show the component when there is no content', () => {
|
||||
const parentFixture = TestBed.createComponent(NoContentComponent);
|
||||
parentFixture.componentInstance.hideIfNoTextContent = false;
|
||||
parentFixture.detectChanges();
|
||||
const parentNative = parentFixture.nativeElement;
|
||||
const nativeWrapper = parentNative.querySelector(wrapperSelector);
|
||||
expect(nativeWrapper.classList.contains('d-none')).toBe(false);
|
||||
});
|
||||
|
||||
it('should show the component when there is text content', () => {
|
||||
const parentFixture = TestBed.createComponent(TextContentComponent);
|
||||
parentFixture.detectChanges();
|
||||
const parentNative = parentFixture.nativeElement;
|
||||
const nativeWrapper = parentNative.querySelector(wrapperSelector);
|
||||
parentFixture.detectChanges();
|
||||
expect(nativeWrapper.classList.contains('d-none')).toBe(false);
|
||||
});
|
||||
it('should show the component when there is no text content', () => {
|
||||
const parentFixture = TestBed.createComponent(SpanContentComponent);
|
||||
parentFixture.componentInstance.hideIfNoTextContent = false;
|
||||
parentFixture.detectChanges();
|
||||
const parentNative = parentFixture.nativeElement;
|
||||
const nativeWrapper = parentNative.querySelector(wrapperSelector);
|
||||
expect(nativeWrapper.classList.contains('d-none')).toBe(false);
|
||||
});
|
||||
|
||||
it('should show the component when there is img content', () => {
|
||||
const parentFixture = TestBed.createComponent(ImgContentComponent);
|
||||
parentFixture.detectChanges();
|
||||
const parentNative = parentFixture.nativeElement;
|
||||
const nativeWrapper = parentNative.querySelector(wrapperSelector);
|
||||
parentFixture.detectChanges();
|
||||
expect(nativeWrapper.classList.contains('d-none')).toBe(false);
|
||||
it('should show the component when there is text content', () => {
|
||||
const parentFixture = TestBed.createComponent(TextContentComponent);
|
||||
parentFixture.componentInstance.hideIfNoTextContent = false;
|
||||
parentFixture.detectChanges();
|
||||
const parentNative = parentFixture.nativeElement;
|
||||
const nativeWrapper = parentNative.querySelector(wrapperSelector);
|
||||
parentFixture.detectChanges();
|
||||
expect(nativeWrapper.classList.contains('d-none')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { hasNoValue } from '../../../shared/empty.util';
|
||||
|
||||
/**
|
||||
* This component renders any content inside this wrapper.
|
||||
@@ -17,10 +16,5 @@ export class MetadataFieldWrapperComponent {
|
||||
*/
|
||||
@Input() label: string;
|
||||
|
||||
/**
|
||||
* Make hasNoValue() available in the template
|
||||
*/
|
||||
hasNoValue(o: any): boolean {
|
||||
return hasNoValue(o);
|
||||
}
|
||||
@Input() hideIfNoTextContent = true;
|
||||
}
|
||||
|
@@ -11,7 +11,7 @@
|
||||
[retainScrollPosition]="true">
|
||||
|
||||
|
||||
<div class="file-section row" *ngFor="let file of originals?.page;">
|
||||
<div class="file-section row mb-3" *ngFor="let file of originals?.page;">
|
||||
<div class="col-3">
|
||||
<ds-thumbnail [thumbnail]="(file.thumbnail | async)?.payload"></ds-thumbnail>
|
||||
</div>
|
||||
|
@@ -68,7 +68,8 @@ export class FullFileSectionComponent extends FileSectionComponent implements On
|
||||
{elementsPerPage: options.pageSize, currentPage: options.currentPage},
|
||||
true,
|
||||
true,
|
||||
followLink('format')
|
||||
followLink('format'),
|
||||
followLink('thumbnail'),
|
||||
)),
|
||||
tap((rd: RemoteData<PaginatedList<Bitstream>>) => {
|
||||
if (hasValue(rd.errorMessage)) {
|
||||
@@ -85,7 +86,8 @@ export class FullFileSectionComponent extends FileSectionComponent implements On
|
||||
{elementsPerPage: options.pageSize, currentPage: options.currentPage},
|
||||
true,
|
||||
true,
|
||||
followLink('format')
|
||||
followLink('format'),
|
||||
followLink('thumbnail'),
|
||||
)),
|
||||
tap((rd: RemoteData<PaginatedList<Bitstream>>) => {
|
||||
if (hasValue(rd.errorMessage)) {
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import {filter, map} from 'rxjs/operators';
|
||||
import { filter, map } from 'rxjs/operators';
|
||||
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { ActivatedRoute, Data, Router } from '@angular/router';
|
||||
|
||||
@@ -11,8 +11,6 @@ import { ItemDataService } from '../../core/data/item-data.service';
|
||||
import { RemoteData } from '../../core/data/remote-data';
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
|
||||
import { MetadataService } from '../../core/metadata/metadata.service';
|
||||
|
||||
import { fadeInOut } from '../../shared/animations/fade';
|
||||
import { hasValue } from '../../shared/empty.util';
|
||||
import { AuthService } from '../../core/auth/auth.service';
|
||||
|
@@ -5,7 +5,6 @@ import { RemoteData } from '../core/data/remote-data';
|
||||
import { ItemDataService } from '../core/data/item-data.service';
|
||||
import { Item } from '../core/shared/item.model';
|
||||
import { followLink, FollowLinkConfig } from '../shared/utils/follow-link-config.model';
|
||||
import { FindListOptions } from '../core/data/request.models';
|
||||
import { getFirstCompletedRemoteData } from '../core/shared/operators';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { ResolvedAction } from '../core/resolving/resolver.actions';
|
||||
@@ -15,13 +14,13 @@ import { ResolvedAction } from '../core/resolving/resolver.actions';
|
||||
* Requesting them as embeds will limit the number of requests
|
||||
*/
|
||||
export const ITEM_PAGE_LINKS_TO_FOLLOW: FollowLinkConfig<Item>[] = [
|
||||
followLink('owningCollection', undefined, true, true, true,
|
||||
followLink('parentCommunity', undefined, true, true, true,
|
||||
followLink('owningCollection', {},
|
||||
followLink('parentCommunity', {},
|
||||
followLink('parentCommunity'))
|
||||
),
|
||||
followLink('bundles', new FindListOptions(), true, true, true, followLink('bitstreams')),
|
||||
followLink('relationships'),
|
||||
followLink('version', undefined, true, true, true, followLink('versionhistory')),
|
||||
followLink('version', {}, followLink('versionhistory')),
|
||||
followLink('thumbnail')
|
||||
];
|
||||
|
||||
/**
|
||||
|
@@ -8,8 +8,6 @@ import { RemoteData } from '../../core/data/remote-data';
|
||||
|
||||
import { Item } from '../../core/shared/item.model';
|
||||
|
||||
import { MetadataService } from '../../core/metadata/metadata.service';
|
||||
|
||||
import { fadeInOut } from '../../shared/animations/fade';
|
||||
import { getAllSucceededRemoteDataPayload, redirectOn4xx } from '../../core/shared/operators';
|
||||
import { ViewMode } from '../../core/shared/view-mode.model';
|
||||
@@ -54,7 +52,6 @@ export class ItemPageComponent implements OnInit {
|
||||
protected route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private items: ItemDataService,
|
||||
private metadataService: MetadataService,
|
||||
private authService: AuthService,
|
||||
) { }
|
||||
|
||||
@@ -66,7 +63,6 @@ export class ItemPageComponent implements OnInit {
|
||||
map((data) => data.dso as RemoteData<Item>),
|
||||
redirectOn4xx(this.router, this.authService)
|
||||
);
|
||||
this.metadataService.processRemoteData(this.itemRD$);
|
||||
this.itemPageRoute$ = this.itemRD$.pipe(
|
||||
getAllSucceededRemoteDataPayload(),
|
||||
map((item) => getItemPageRoute(item))
|
||||
|
@@ -9,8 +9,8 @@
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-4">
|
||||
<ng-container *ngIf="!mediaViewer.image">
|
||||
<ds-metadata-field-wrapper>
|
||||
<ds-thumbnail [thumbnail]="getThumbnail() | async"></ds-thumbnail>
|
||||
<ds-metadata-field-wrapper [hideIfNoTextContent]="false">
|
||||
<ds-thumbnail [thumbnail]="object?.thumbnail | async"></ds-thumbnail>
|
||||
</ds-metadata-field-wrapper>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="mediaViewer.image">
|
||||
|
@@ -1,10 +1,6 @@
|
||||
import { Component, Input, OnInit } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { environment } from '../../../../../environments/environment';
|
||||
import { BitstreamDataService } from '../../../../core/data/bitstream-data.service';
|
||||
import { Bitstream } from '../../../../core/shared/bitstream.model';
|
||||
import { Item } from '../../../../core/shared/item.model';
|
||||
import { getFirstSucceededRemoteDataPayload } from '../../../../core/shared/operators';
|
||||
import { getItemPageRoute } from '../../../item-page-routing-paths';
|
||||
|
||||
@Component({
|
||||
@@ -21,19 +17,10 @@ export class ItemComponent implements OnInit {
|
||||
* Route to the item page
|
||||
*/
|
||||
itemPageRoute: string;
|
||||
mediaViewer = environment.mediaViewer;
|
||||
|
||||
constructor(protected bitstreamDataService: BitstreamDataService) {
|
||||
}
|
||||
mediaViewer = environment.mediaViewer;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.itemPageRoute = getItemPageRoute(this.object);
|
||||
}
|
||||
|
||||
// TODO refactor to return RemoteData, and thumbnail template to deal with loading
|
||||
getThumbnail(): Observable<Bitstream> {
|
||||
return this.bitstreamDataService.getThumbnailFor(this.object).pipe(
|
||||
getFirstSucceededRemoteDataPayload()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -9,8 +9,8 @@
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-4">
|
||||
<ng-container *ngIf="!mediaViewer.image">
|
||||
<ds-metadata-field-wrapper>
|
||||
<ds-thumbnail [thumbnail]="getThumbnail() | async"></ds-thumbnail>
|
||||
<ds-metadata-field-wrapper [hideIfNoTextContent]="false">
|
||||
<ds-thumbnail [thumbnail]="object?.thumbnail | async"></ds-thumbnail>
|
||||
</ds-metadata-field-wrapper>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="mediaViewer.image">
|
||||
|
@@ -16,9 +16,11 @@ import { SearchResult } from '../shared/search/search-result.model';
|
||||
import { SearchConfigurationService } from '../core/shared/search/search-configuration.service';
|
||||
import { SearchService } from '../core/shared/search/search.service';
|
||||
import { currentPath } from '../shared/utils/route.utils';
|
||||
import { Router} from '@angular/router';
|
||||
import { Router } from '@angular/router';
|
||||
import { Context } from '../core/shared/context.model';
|
||||
import { SortOptions } from '../core/cache/models/sort-options.model';
|
||||
import { followLink } from '../shared/utils/follow-link-config.model';
|
||||
import { Item } from '../core/shared/item.model';
|
||||
|
||||
@Component({
|
||||
selector: 'ds-search',
|
||||
@@ -128,8 +130,11 @@ export class SearchComponent implements OnInit {
|
||||
this.searchLink = this.getSearchLink();
|
||||
this.searchOptions$ = this.getSearchOptions();
|
||||
this.sub = this.searchOptions$.pipe(
|
||||
switchMap((options) => this.service.search(options).pipe(getFirstSucceededRemoteData(), startWith(undefined))))
|
||||
.subscribe((results) => {
|
||||
switchMap((options) => this.service.search(
|
||||
options, undefined, true, true, followLink<Item>('thumbnail', { isOptional: true })
|
||||
).pipe(getFirstSucceededRemoteData(), startWith(undefined))
|
||||
)
|
||||
).subscribe((results) => {
|
||||
this.resultsRD$.next(results);
|
||||
});
|
||||
this.scopeListRD$ = this.searchConfigService.getCurrentScope('').pipe(
|
||||
|
@@ -3,6 +3,10 @@ import { getAccessControlModuleRoute } from '../app-routing-paths';
|
||||
|
||||
export const GROUP_EDIT_PATH = 'groups';
|
||||
|
||||
export function getGroupsRoute() {
|
||||
return new URLCombiner(getAccessControlModuleRoute(), GROUP_EDIT_PATH).toString();
|
||||
}
|
||||
|
||||
export function getGroupEditRoute(id: string) {
|
||||
return new URLCombiner(getAccessControlModuleRoute(), GROUP_EDIT_PATH, id).toString();
|
||||
}
|
||||
|
@@ -5,6 +5,9 @@ import { GroupFormComponent } from './group-registry/group-form/group-form.compo
|
||||
import { GroupsRegistryComponent } from './group-registry/groups-registry.component';
|
||||
import { GROUP_EDIT_PATH } from './access-control-routing-paths';
|
||||
import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.resolver';
|
||||
import { GroupPageGuard } from './group-registry/group-page.guard';
|
||||
import { GroupAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/group-administrator.guard';
|
||||
import { SiteAdministratorGuard } from '../core/data/feature-authorization/feature-authorization-guard/site-administrator.guard';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -15,7 +18,8 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso
|
||||
resolve: {
|
||||
breadcrumb: I18nBreadcrumbResolver
|
||||
},
|
||||
data: { title: 'admin.access-control.epeople.title', breadcrumbKey: 'admin.access-control.epeople' }
|
||||
data: { title: 'admin.access-control.epeople.title', breadcrumbKey: 'admin.access-control.epeople' },
|
||||
canActivate: [SiteAdministratorGuard]
|
||||
},
|
||||
{
|
||||
path: GROUP_EDIT_PATH,
|
||||
@@ -23,7 +27,8 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso
|
||||
resolve: {
|
||||
breadcrumb: I18nBreadcrumbResolver
|
||||
},
|
||||
data: { title: 'admin.access-control.groups.title', breadcrumbKey: 'admin.access-control.groups' }
|
||||
data: { title: 'admin.access-control.groups.title', breadcrumbKey: 'admin.access-control.groups' },
|
||||
canActivate: [GroupAdministratorGuard]
|
||||
},
|
||||
{
|
||||
path: `${GROUP_EDIT_PATH}/newGroup`,
|
||||
@@ -31,7 +36,8 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso
|
||||
resolve: {
|
||||
breadcrumb: I18nBreadcrumbResolver
|
||||
},
|
||||
data: { title: 'admin.access-control.groups.title.addGroup', breadcrumbKey: 'admin.access-control.groups.addGroup' }
|
||||
data: { title: 'admin.access-control.groups.title.addGroup', breadcrumbKey: 'admin.access-control.groups.addGroup' },
|
||||
canActivate: [GroupAdministratorGuard]
|
||||
},
|
||||
{
|
||||
path: `${GROUP_EDIT_PATH}/:groupId`,
|
||||
@@ -39,7 +45,8 @@ import { I18nBreadcrumbResolver } from '../core/breadcrumbs/i18n-breadcrumb.reso
|
||||
resolve: {
|
||||
breadcrumb: I18nBreadcrumbResolver
|
||||
},
|
||||
data: { title: 'admin.access-control.groups.title.singleGroup', breadcrumbKey: 'admin.access-control.groups.singleGroup' }
|
||||
data: { title: 'admin.access-control.groups.title.singleGroup', breadcrumbKey: 'admin.access-control.groups.singleGroup' },
|
||||
canActivate: [GroupPageGuard]
|
||||
}
|
||||
])
|
||||
]
|
||||
|
@@ -0,0 +1,83 @@
|
||||
import { GroupPageGuard } from './group-page.guard';
|
||||
import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
|
||||
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
|
||||
import { ActivatedRouteSnapshot, Router } from '@angular/router';
|
||||
import { of as observableOf } from 'rxjs';
|
||||
import { AuthService } from '../../core/auth/auth.service';
|
||||
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
|
||||
|
||||
describe('GroupPageGuard', () => {
|
||||
const groupsEndpointUrl = 'https://test.org/api/eperson/groups';
|
||||
const groupUuid = '0d6f89df-f95a-4829-943c-f21f434fb892';
|
||||
const groupEndpointUrl = `${groupsEndpointUrl}/${groupUuid}`;
|
||||
const routeSnapshotWithGroupId = {
|
||||
params: {
|
||||
groupId: groupUuid,
|
||||
}
|
||||
} as unknown as ActivatedRouteSnapshot;
|
||||
|
||||
let guard: GroupPageGuard;
|
||||
let halEndpointService: HALEndpointService;
|
||||
let authorizationService: AuthorizationDataService;
|
||||
let router: Router;
|
||||
let authService: AuthService;
|
||||
|
||||
beforeEach(() => {
|
||||
halEndpointService = jasmine.createSpyObj(['getEndpoint']);
|
||||
(halEndpointService as any).getEndpoint.and.returnValue(observableOf(groupsEndpointUrl));
|
||||
|
||||
authorizationService = jasmine.createSpyObj(['isAuthorized']);
|
||||
// NOTE: value is set in beforeEach
|
||||
|
||||
router = jasmine.createSpyObj(['parseUrl']);
|
||||
(router as any).parseUrl.and.returnValue = {};
|
||||
|
||||
authService = jasmine.createSpyObj(['isAuthenticated']);
|
||||
(authService as any).isAuthenticated.and.returnValue(observableOf(true));
|
||||
|
||||
guard = new GroupPageGuard(halEndpointService, authorizationService, router, authService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(guard).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('canActivate', () => {
|
||||
describe('when the current user can manage the group', () => {
|
||||
beforeEach(() => {
|
||||
(authorizationService as any).isAuthorized.and.returnValue(observableOf(true));
|
||||
});
|
||||
|
||||
it('should return true', (done) => {
|
||||
guard.canActivate(
|
||||
routeSnapshotWithGroupId, { url: 'current-url'} as any
|
||||
).subscribe((result) => {
|
||||
expect(authorizationService.isAuthorized).toHaveBeenCalledWith(
|
||||
FeatureID.CanManageGroup, groupEndpointUrl, undefined
|
||||
);
|
||||
expect(result).toBeTrue();
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the current user can not manage the group', () => {
|
||||
beforeEach(() => {
|
||||
(authorizationService as any).isAuthorized.and.returnValue(observableOf(false));
|
||||
});
|
||||
|
||||
it('should not return true', (done) => {
|
||||
guard.canActivate(
|
||||
routeSnapshotWithGroupId, { url: 'current-url'} as any
|
||||
).subscribe((result) => {
|
||||
expect(authorizationService.isAuthorized).toHaveBeenCalledWith(
|
||||
FeatureID.CanManageGroup, groupEndpointUrl, undefined
|
||||
);
|
||||
expect(result).not.toBeTrue();
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
35
src/app/access-control/group-registry/group-page.guard.ts
Normal file
35
src/app/access-control/group-registry/group-page.guard.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { ActivatedRouteSnapshot, Router, RouterStateSnapshot } from '@angular/router';
|
||||
import { Observable, of as observableOf } from 'rxjs';
|
||||
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
|
||||
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
|
||||
import { AuthService } from '../../core/auth/auth.service';
|
||||
import { SomeFeatureAuthorizationGuard } from '../../core/data/feature-authorization/feature-authorization-guard/some-feature-authorization.guard';
|
||||
import { HALEndpointService } from '../../core/shared/hal-endpoint.service';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class GroupPageGuard extends SomeFeatureAuthorizationGuard {
|
||||
|
||||
protected groupsEndpoint = 'groups';
|
||||
|
||||
constructor(protected halEndpointService: HALEndpointService,
|
||||
protected authorizationService: AuthorizationDataService,
|
||||
protected router: Router,
|
||||
protected authService: AuthService) {
|
||||
super(authorizationService, router, authService);
|
||||
}
|
||||
|
||||
getFeatureIDs(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<FeatureID[]> {
|
||||
return observableOf([FeatureID.CanManageGroup]);
|
||||
}
|
||||
|
||||
getObjectUrl(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<string> {
|
||||
return this.halEndpointService.getEndpoint(this.groupsEndpoint).pipe(
|
||||
map(groupsUrl => `${groupsUrl}/${route?.params?.groupId}`)
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@@ -33,9 +33,9 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ds-loading *ngIf="searching$ | async"></ds-loading>
|
||||
<ds-loading *ngIf="loading$ | async"></ds-loading>
|
||||
<ds-pagination
|
||||
*ngIf="(pageInfoState$ | async)?.totalElements > 0 && !(searching$ | async)"
|
||||
*ngIf="(pageInfoState$ | async)?.totalElements > 0 && !(loading$ | async)"
|
||||
[paginationOptions]="config"
|
||||
[pageInfoState]="pageInfoState$"
|
||||
[collectionSize]="(pageInfoState$ | async)?.totalElements"
|
||||
@@ -59,11 +59,23 @@
|
||||
<td>{{groupDto.epersons?.totalElements + groupDto.subgroups?.totalElements}}</td>
|
||||
<td>
|
||||
<div class="btn-group edit-field">
|
||||
<button [routerLink]="groupService.getGroupEditPageRouterLink(groupDto.group)"
|
||||
class="btn btn-outline-primary btn-sm"
|
||||
title="{{messagePrefix + 'table.edit.buttons.edit' | translate: {name: groupDto.group.name} }}">
|
||||
<i class="fas fa-edit fa-fw"></i>
|
||||
</button>
|
||||
<ng-container [ngSwitch]="groupDto.ableToEdit">
|
||||
<button *ngSwitchCase="true"
|
||||
[routerLink]="groupService.getGroupEditPageRouterLink(groupDto.group)"
|
||||
class="btn btn-outline-primary btn-sm btn-edit"
|
||||
title="{{messagePrefix + 'table.edit.buttons.edit' | translate: {name: groupDto.group.name} }}"
|
||||
>
|
||||
<i class="fas fa-edit fa-fw"></i>
|
||||
</button>
|
||||
<button *ngSwitchCase="false"
|
||||
[disabled]="true"
|
||||
class="btn btn-outline-primary btn-sm btn-edit"
|
||||
placement="left"
|
||||
[ngbTooltip]="'admin.access-control.epeople.table.edit.buttons.edit-disabled' | translate"
|
||||
>
|
||||
<i class="fas fa-edit fa-fw"></i>
|
||||
</button>
|
||||
</ng-container>
|
||||
<button *ngIf="!groupDto.group?.permanent && groupDto.ableToDelete"
|
||||
(click)="deleteGroup(groupDto)" class="btn btn-outline-danger btn-sm"
|
||||
title="{{messagePrefix + 'table.edit.buttons.remove' | translate: {name: groupDto.group.name} }}">
|
||||
|
@@ -30,6 +30,7 @@ import { routeServiceStub } from '../../shared/testing/route-service.stub';
|
||||
import { RouterMock } from '../../shared/mocks/router.mock';
|
||||
import { PaginationService } from '../../core/pagination/pagination.service';
|
||||
import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub';
|
||||
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
|
||||
|
||||
describe('GroupRegistryComponent', () => {
|
||||
let component: GroupsRegistryComponent;
|
||||
@@ -43,6 +44,26 @@ describe('GroupRegistryComponent', () => {
|
||||
let mockEPeople;
|
||||
let paginationService;
|
||||
|
||||
/**
|
||||
* Set authorizationService.isAuthorized to return the following values.
|
||||
* @param isAdmin whether or not the current user is an admin.
|
||||
* @param canManageGroup whether or not the current user can manage all groups.
|
||||
*/
|
||||
const setIsAuthorized = (isAdmin: boolean, canManageGroup: boolean) => {
|
||||
(authorizationService as any).isAuthorized.and.callFake((featureId?: FeatureID) => {
|
||||
switch (featureId) {
|
||||
case FeatureID.AdministratorOf:
|
||||
return observableOf(isAdmin);
|
||||
case FeatureID.CanManageGroup:
|
||||
return observableOf(canManageGroup);
|
||||
case FeatureID.CanDelete:
|
||||
return observableOf(true);
|
||||
default:
|
||||
throw new Error(`setIsAuthorized: this fake implementation does not support ${featureId}.`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
mockGroups = [GroupMock, GroupMock2];
|
||||
mockEPeople = [EPersonMock, EPersonMock2];
|
||||
@@ -131,9 +152,8 @@ describe('GroupRegistryComponent', () => {
|
||||
return createSuccessfulRemoteDataObject$(undefined);
|
||||
}
|
||||
};
|
||||
authorizationService = jasmine.createSpyObj('authorizationService', {
|
||||
isAuthorized: observableOf(true)
|
||||
});
|
||||
authorizationService = jasmine.createSpyObj('authorizationService', ['isAuthorized']);
|
||||
setIsAuthorized(true, true);
|
||||
paginationService = new PaginationServiceStub();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CommonModule, NgbModule, FormsModule, ReactiveFormsModule, BrowserModule,
|
||||
@@ -180,6 +200,81 @@ describe('GroupRegistryComponent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit buttons', () => {
|
||||
describe('when the user is a general admin', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
// NOTE: setting canManageGroup to false should not matter, since isAdmin takes priority
|
||||
setIsAuthorized(true, false);
|
||||
|
||||
// force rerender after setup changes
|
||||
component.search({ query: '' });
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should be active', () => {
|
||||
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(4) button.btn-edit'));
|
||||
expect(editButtonsFound.length).toEqual(2);
|
||||
editButtonsFound.forEach((editButtonFound) => {
|
||||
expect(editButtonFound.nativeElement.disabled).toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not check the canManageGroup permissions', () => {
|
||||
expect(authorizationService.isAuthorized).not.toHaveBeenCalledWith(
|
||||
FeatureID.CanManageGroup, mockGroups[0].self
|
||||
);
|
||||
expect(authorizationService.isAuthorized).not.toHaveBeenCalledWith(
|
||||
FeatureID.CanManageGroup, mockGroups[0].self, undefined // treated differently
|
||||
);
|
||||
expect(authorizationService.isAuthorized).not.toHaveBeenCalledWith(
|
||||
FeatureID.CanManageGroup, mockGroups[1].self
|
||||
);
|
||||
expect(authorizationService.isAuthorized).not.toHaveBeenCalledWith(
|
||||
FeatureID.CanManageGroup, mockGroups[1].self, undefined // treated differently
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the user can edit the groups', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
setIsAuthorized(false, true);
|
||||
|
||||
// force rerender after setup changes
|
||||
component.search({ query: '' });
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should be active', () => {
|
||||
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(4) button.btn-edit'));
|
||||
expect(editButtonsFound.length).toEqual(2);
|
||||
editButtonsFound.forEach((editButtonFound) => {
|
||||
expect(editButtonFound.nativeElement.disabled).toBeFalse();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the user can not edit the groups', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
setIsAuthorized(false, false);
|
||||
|
||||
// force rerender after setup changes
|
||||
component.search({ query: '' });
|
||||
tick();
|
||||
fixture.detectChanges();
|
||||
}));
|
||||
|
||||
it('should not be active', () => {
|
||||
const editButtonsFound = fixture.debugElement.queryAll(By.css('#groups tr td:nth-child(4) button.btn-edit'));
|
||||
expect(editButtonsFound.length).toEqual(2);
|
||||
editButtonsFound.forEach((editButtonFound) => {
|
||||
expect(editButtonFound.nativeElement.disabled).toBeTrue();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
describe('when searching with query', () => {
|
||||
let groupIdsFound;
|
||||
|
@@ -9,7 +9,7 @@ import {
|
||||
of as observableOf,
|
||||
Subscription
|
||||
} from 'rxjs';
|
||||
import { catchError, map, switchMap, take } from 'rxjs/operators';
|
||||
import { catchError, map, switchMap, take, tap } from 'rxjs/operators';
|
||||
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
|
||||
import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service';
|
||||
import { FeatureID } from '../../core/data/feature-authorization/feature-id';
|
||||
@@ -75,7 +75,7 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
|
||||
/**
|
||||
* A boolean representing if a search is pending
|
||||
*/
|
||||
searching$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
||||
loading$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
||||
|
||||
// Current search in groups registry
|
||||
currentSearchQuery: string;
|
||||
@@ -118,12 +118,12 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
|
||||
* @param data Contains query param
|
||||
*/
|
||||
search(data: any) {
|
||||
this.searching$.next(true);
|
||||
if (hasValue(this.searchSub)) {
|
||||
this.searchSub.unsubscribe();
|
||||
this.subs = this.subs.filter((sub: Subscription) => sub !== this.searchSub);
|
||||
}
|
||||
this.searchSub = this.paginationService.getCurrentPagination(this.config.id, this.config).pipe(
|
||||
tap(() => this.loading$.next(true)),
|
||||
switchMap((paginationOptions) => {
|
||||
const query: string = data.query;
|
||||
if (query != null && this.currentSearchQuery !== query) {
|
||||
@@ -141,39 +141,53 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
|
||||
if (groups.page.length === 0) {
|
||||
return observableOf(buildPaginatedList(groups.pageInfo, []));
|
||||
}
|
||||
return observableCombineLatest(groups.page.map((group: Group) => {
|
||||
if (!this.deletedGroupsIds.includes(group.id)) {
|
||||
return observableCombineLatest([
|
||||
this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(group) ? group.self : undefined),
|
||||
this.hasLinkedDSO(group),
|
||||
this.getSubgroups(group),
|
||||
this.getMembers(group)
|
||||
]).pipe(
|
||||
map(([isAuthorized, hasLinkedDSO, subgroups, members]:
|
||||
[boolean, boolean, RemoteData<PaginatedList<Group>>, RemoteData<PaginatedList<EPerson>>]) => {
|
||||
const groupDtoModel: GroupDtoModel = new GroupDtoModel();
|
||||
groupDtoModel.ableToDelete = isAuthorized && !hasLinkedDSO;
|
||||
groupDtoModel.group = group;
|
||||
groupDtoModel.subgroups = subgroups.payload;
|
||||
groupDtoModel.epersons = members.payload;
|
||||
return groupDtoModel;
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
})).pipe(map((dtos: GroupDtoModel[]) => {
|
||||
return buildPaginatedList(groups.pageInfo, dtos);
|
||||
}));
|
||||
return this.authorizationService.isAuthorized(FeatureID.AdministratorOf).pipe(
|
||||
switchMap((isSiteAdmin: boolean) => {
|
||||
return observableCombineLatest(groups.page.map((group: Group) => {
|
||||
if (hasValue(group) && !this.deletedGroupsIds.includes(group.id)) {
|
||||
return observableCombineLatest([
|
||||
this.authorizationService.isAuthorized(FeatureID.CanDelete, group.self),
|
||||
this.canManageGroup$(isSiteAdmin, group),
|
||||
this.hasLinkedDSO(group),
|
||||
this.getSubgroups(group),
|
||||
this.getMembers(group)
|
||||
]).pipe(
|
||||
map(([canDelete, canManageGroup, hasLinkedDSO, subgroups, members]:
|
||||
[boolean, boolean, boolean, RemoteData<PaginatedList<Group>>, RemoteData<PaginatedList<EPerson>>]) => {
|
||||
const groupDtoModel: GroupDtoModel = new GroupDtoModel();
|
||||
groupDtoModel.ableToDelete = canDelete && !hasLinkedDSO;
|
||||
groupDtoModel.ableToEdit = canManageGroup;
|
||||
groupDtoModel.group = group;
|
||||
groupDtoModel.subgroups = subgroups.payload;
|
||||
groupDtoModel.epersons = members.payload;
|
||||
return groupDtoModel;
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
})).pipe(map((dtos: GroupDtoModel[]) => {
|
||||
return buildPaginatedList(groups.pageInfo, dtos);
|
||||
}));
|
||||
})
|
||||
);
|
||||
})
|
||||
).subscribe((value: PaginatedList<GroupDtoModel>) => {
|
||||
this.groupsDto$.next(value);
|
||||
this.pageInfoState$.next(value.pageInfo);
|
||||
this.searching$.next(false);
|
||||
this.loading$.next(false);
|
||||
});
|
||||
|
||||
this.subs.push(this.searchSub);
|
||||
}
|
||||
|
||||
canManageGroup$(isSiteAdmin: boolean, group: Group): Observable<boolean> {
|
||||
if (isSiteAdmin) {
|
||||
return observableOf(true);
|
||||
} else {
|
||||
return this.authorizationService.isAuthorized(FeatureID.CanManageGroup, group.self);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Group
|
||||
*/
|
||||
|
@@ -174,8 +174,8 @@ export class CommunityListService {
|
||||
direction: options.sort.direction
|
||||
}
|
||||
},
|
||||
followLink('subcommunities', this.configOnePage, true, true),
|
||||
followLink('collections', this.configOnePage, true, true))
|
||||
followLink('subcommunities', { findListOptions: this.configOnePage }),
|
||||
followLink('collections', { findListOptions: this.configOnePage }))
|
||||
.pipe(
|
||||
getFirstSucceededRemoteData(),
|
||||
map((results) => results.payload),
|
||||
@@ -242,8 +242,8 @@ export class CommunityListService {
|
||||
elementsPerPage: MAX_COMCOLS_PER_PAGE,
|
||||
currentPage: i
|
||||
},
|
||||
followLink('subcommunities', this.configOnePage, true, true),
|
||||
followLink('collections', this.configOnePage, true, true))
|
||||
followLink('subcommunities', { findListOptions: this.configOnePage }),
|
||||
followLink('collections', { findListOptions: this.configOnePage }))
|
||||
.pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
switchMap((rd: RemoteData<PaginatedList<Community>>) => {
|
||||
|
@@ -292,10 +292,13 @@ export class ResetAuthenticationMessagesAction implements Action {
|
||||
export class RetrieveAuthMethodsAction implements Action {
|
||||
public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS;
|
||||
|
||||
payload: AuthStatus;
|
||||
payload: {
|
||||
status: AuthStatus;
|
||||
blocking: boolean;
|
||||
};
|
||||
|
||||
constructor(authStatus: AuthStatus) {
|
||||
this.payload = authStatus;
|
||||
constructor(status: AuthStatus, blocking: boolean) {
|
||||
this.payload = { status, blocking };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,10 +309,14 @@ export class RetrieveAuthMethodsAction implements Action {
|
||||
*/
|
||||
export class RetrieveAuthMethodsSuccessAction implements Action {
|
||||
public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS;
|
||||
payload: AuthMethod[];
|
||||
|
||||
constructor(authMethods: AuthMethod[] ) {
|
||||
this.payload = authMethods;
|
||||
payload: {
|
||||
authMethods: AuthMethod[];
|
||||
blocking: boolean;
|
||||
};
|
||||
|
||||
constructor(authMethods: AuthMethod[], blocking: boolean ) {
|
||||
this.payload = { authMethods, blocking };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,6 +327,12 @@ export class RetrieveAuthMethodsSuccessAction implements Action {
|
||||
*/
|
||||
export class RetrieveAuthMethodsErrorAction implements Action {
|
||||
public type: string = AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR;
|
||||
|
||||
payload: boolean;
|
||||
|
||||
constructor(blocking: boolean) {
|
||||
this.payload = blocking;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -43,10 +43,12 @@ describe('AuthEffects', () => {
|
||||
let initialState;
|
||||
let token;
|
||||
let store: MockStore<AppState>;
|
||||
let authStatus;
|
||||
|
||||
function init() {
|
||||
authServiceStub = new AuthServiceStub();
|
||||
token = authServiceStub.getToken();
|
||||
authStatus = Object.assign(new AuthStatus(), {});
|
||||
initialState = {
|
||||
core: {
|
||||
auth: {
|
||||
@@ -217,16 +219,38 @@ describe('AuthEffects', () => {
|
||||
expect(authEffects.checkTokenCookie$).toBeObservable(expected);
|
||||
});
|
||||
|
||||
it('should return a RETRIEVE_AUTH_METHODS action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is false', () => {
|
||||
spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue(
|
||||
observableOf(
|
||||
{ authenticated: false })
|
||||
);
|
||||
actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } });
|
||||
describe('on CSR', () => {
|
||||
it('should return a RETRIEVE_AUTH_METHODS action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is false', () => {
|
||||
spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue(
|
||||
observableOf(
|
||||
{ authenticated: false })
|
||||
);
|
||||
spyOn((authEffects as any).authService, 'getRetrieveAuthMethodsAction').and.returnValue(
|
||||
new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus, false)
|
||||
);
|
||||
actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } });
|
||||
|
||||
const expected = cold('--b-', { b: new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus) });
|
||||
const expected = cold('--b-', { b: new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus, false) });
|
||||
|
||||
expect(authEffects.checkTokenCookie$).toBeObservable(expected);
|
||||
expect(authEffects.checkTokenCookie$).toBeObservable(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('on SSR', () => {
|
||||
it('should return a RETRIEVE_AUTH_METHODS action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action when authenticated is false', () => {
|
||||
spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue(
|
||||
observableOf(
|
||||
{ authenticated: false })
|
||||
);
|
||||
spyOn((authEffects as any).authService, 'getRetrieveAuthMethodsAction').and.returnValue(
|
||||
new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus, true)
|
||||
);
|
||||
actions = hot('--a-', { a: { type: AuthActionTypes.CHECK_AUTHENTICATION_TOKEN_COOKIE } });
|
||||
|
||||
const expected = cold('--b-', { b: new RetrieveAuthMethodsAction({ authenticated: false } as AuthStatus, true) });
|
||||
|
||||
expect(authEffects.checkTokenCookie$).toBeObservable(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -359,27 +383,74 @@ describe('AuthEffects', () => {
|
||||
|
||||
describe('retrieveMethods$', () => {
|
||||
|
||||
describe('when retrieve authentication methods succeeded', () => {
|
||||
it('should return a RETRIEVE_AUTH_METHODS_SUCCESS action in response to a RETRIEVE_AUTH_METHODS action', () => {
|
||||
actions = hot('--a-', { a: { type: AuthActionTypes.RETRIEVE_AUTH_METHODS } });
|
||||
describe('on CSR', () => {
|
||||
describe('when retrieve authentication methods succeeded', () => {
|
||||
it('should return a RETRIEVE_AUTH_METHODS_SUCCESS action in response to a RETRIEVE_AUTH_METHODS action', () => {
|
||||
actions = hot('--a-', { a:
|
||||
{
|
||||
type: AuthActionTypes.RETRIEVE_AUTH_METHODS,
|
||||
payload: { status: authStatus, blocking: false}
|
||||
}
|
||||
});
|
||||
|
||||
const expected = cold('--b-', { b: new RetrieveAuthMethodsSuccessAction(authMethodsMock) });
|
||||
const expected = cold('--b-', { b: new RetrieveAuthMethodsSuccessAction(authMethodsMock, false) });
|
||||
|
||||
expect(authEffects.retrieveMethods$).toBeObservable(expected);
|
||||
expect(authEffects.retrieveMethods$).toBeObservable(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when retrieve authentication methods failed', () => {
|
||||
it('should return a RETRIEVE_AUTH_METHODS_ERROR action in response to a RETRIEVE_AUTH_METHODS action', () => {
|
||||
spyOn((authEffects as any).authService, 'retrieveAuthMethodsFromAuthStatus').and.returnValue(observableThrow(''));
|
||||
|
||||
actions = hot('--a-', { a:
|
||||
{
|
||||
type: AuthActionTypes.RETRIEVE_AUTH_METHODS,
|
||||
payload: { status: authStatus, blocking: false}
|
||||
}
|
||||
});
|
||||
|
||||
const expected = cold('--b-', { b: new RetrieveAuthMethodsErrorAction(false) });
|
||||
|
||||
expect(authEffects.retrieveMethods$).toBeObservable(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when retrieve authentication methods failed', () => {
|
||||
it('should return a RETRIEVE_AUTH_METHODS_ERROR action in response to a RETRIEVE_AUTH_METHODS action', () => {
|
||||
spyOn((authEffects as any).authService, 'retrieveAuthMethodsFromAuthStatus').and.returnValue(observableThrow(''));
|
||||
describe('on SSR', () => {
|
||||
describe('when retrieve authentication methods succeeded', () => {
|
||||
it('should return a RETRIEVE_AUTH_METHODS_SUCCESS action in response to a RETRIEVE_AUTH_METHODS action', () => {
|
||||
actions = hot('--a-', { a:
|
||||
{
|
||||
type: AuthActionTypes.RETRIEVE_AUTH_METHODS,
|
||||
payload: { status: authStatus, blocking: true}
|
||||
}
|
||||
});
|
||||
|
||||
actions = hot('--a-', { a: { type: AuthActionTypes.RETRIEVE_AUTH_METHODS } });
|
||||
const expected = cold('--b-', { b: new RetrieveAuthMethodsSuccessAction(authMethodsMock, true) });
|
||||
|
||||
const expected = cold('--b-', { b: new RetrieveAuthMethodsErrorAction() });
|
||||
expect(authEffects.retrieveMethods$).toBeObservable(expected);
|
||||
});
|
||||
});
|
||||
|
||||
expect(authEffects.retrieveMethods$).toBeObservable(expected);
|
||||
describe('when retrieve authentication methods failed', () => {
|
||||
it('should return a RETRIEVE_AUTH_METHODS_ERROR action in response to a RETRIEVE_AUTH_METHODS action', () => {
|
||||
spyOn((authEffects as any).authService, 'retrieveAuthMethodsFromAuthStatus').and.returnValue(observableThrow(''));
|
||||
|
||||
actions = hot('--a-', { a:
|
||||
{
|
||||
type: AuthActionTypes.RETRIEVE_AUTH_METHODS,
|
||||
payload: { status: authStatus, blocking: true}
|
||||
}
|
||||
});
|
||||
|
||||
const expected = cold('--b-', { b: new RetrieveAuthMethodsErrorAction(true) });
|
||||
|
||||
expect(authEffects.retrieveMethods$).toBeObservable(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('clearInvalidTokenOnRehydrate$', () => {
|
||||
|
@@ -145,7 +145,7 @@ export class AuthEffects {
|
||||
if (response.authenticated) {
|
||||
return new RetrieveTokenAction();
|
||||
} else {
|
||||
return new RetrieveAuthMethodsAction(response);
|
||||
return this.authService.getRetrieveAuthMethodsAction(response);
|
||||
}
|
||||
}),
|
||||
catchError((error) => observableOf(new AuthenticatedErrorAction(error)))
|
||||
@@ -234,10 +234,10 @@ export class AuthEffects {
|
||||
.pipe(
|
||||
ofType(AuthActionTypes.RETRIEVE_AUTH_METHODS),
|
||||
switchMap((action: RetrieveAuthMethodsAction) => {
|
||||
return this.authService.retrieveAuthMethodsFromAuthStatus(action.payload)
|
||||
return this.authService.retrieveAuthMethodsFromAuthStatus(action.payload.status)
|
||||
.pipe(
|
||||
map((authMethodModels: AuthMethod[]) => new RetrieveAuthMethodsSuccessAction(authMethodModels)),
|
||||
catchError((error) => observableOf(new RetrieveAuthMethodsErrorAction()))
|
||||
map((authMethodModels: AuthMethod[]) => new RetrieveAuthMethodsSuccessAction(authMethodModels, action.payload.blocking)),
|
||||
catchError((error) => observableOf(new RetrieveAuthMethodsErrorAction(action.payload.blocking)))
|
||||
);
|
||||
})
|
||||
);
|
||||
|
@@ -512,7 +512,7 @@ describe('authReducer', () => {
|
||||
loading: false,
|
||||
authMethods: []
|
||||
};
|
||||
const action = new RetrieveAuthMethodsAction(new AuthStatus());
|
||||
const action = new RetrieveAuthMethodsAction(new AuthStatus(), true);
|
||||
const newState = authReducer(initialState, action);
|
||||
state = {
|
||||
authenticated: false,
|
||||
@@ -536,7 +536,7 @@ describe('authReducer', () => {
|
||||
new AuthMethod(AuthMethodType.Password),
|
||||
new AuthMethod(AuthMethodType.Shibboleth, 'location')
|
||||
];
|
||||
const action = new RetrieveAuthMethodsSuccessAction(authMethods);
|
||||
const action = new RetrieveAuthMethodsSuccessAction(authMethods, false);
|
||||
const newState = authReducer(initialState, action);
|
||||
state = {
|
||||
authenticated: false,
|
||||
@@ -548,7 +548,31 @@ describe('authReducer', () => {
|
||||
expect(newState).toEqual(state);
|
||||
});
|
||||
|
||||
it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_ERROR action', () => {
|
||||
it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_SUCCESS action with blocking as true', () => {
|
||||
initialState = {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
blocking: true,
|
||||
loading: true,
|
||||
authMethods: []
|
||||
};
|
||||
const authMethods = [
|
||||
new AuthMethod(AuthMethodType.Password),
|
||||
new AuthMethod(AuthMethodType.Shibboleth, 'location')
|
||||
];
|
||||
const action = new RetrieveAuthMethodsSuccessAction(authMethods, true);
|
||||
const newState = authReducer(initialState, action);
|
||||
state = {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
blocking: true,
|
||||
loading: false,
|
||||
authMethods: authMethods
|
||||
};
|
||||
expect(newState).toEqual(state);
|
||||
});
|
||||
|
||||
it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_ERROR action ', () => {
|
||||
initialState = {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
@@ -557,7 +581,7 @@ describe('authReducer', () => {
|
||||
authMethods: []
|
||||
};
|
||||
|
||||
const action = new RetrieveAuthMethodsErrorAction();
|
||||
const action = new RetrieveAuthMethodsErrorAction(false);
|
||||
const newState = authReducer(initialState, action);
|
||||
state = {
|
||||
authenticated: false,
|
||||
@@ -568,4 +592,25 @@ describe('authReducer', () => {
|
||||
};
|
||||
expect(newState).toEqual(state);
|
||||
});
|
||||
|
||||
it('should properly set the state, in response to a RETRIEVE_AUTH_METHODS_ERROR action with blocking as true', () => {
|
||||
initialState = {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
blocking: true,
|
||||
loading: true,
|
||||
authMethods: []
|
||||
};
|
||||
|
||||
const action = new RetrieveAuthMethodsErrorAction(true);
|
||||
const newState = authReducer(initialState, action);
|
||||
state = {
|
||||
authenticated: false,
|
||||
loaded: false,
|
||||
blocking: true,
|
||||
loading: false,
|
||||
authMethods: [new AuthMethod(AuthMethodType.Password)]
|
||||
};
|
||||
expect(newState).toEqual(state);
|
||||
});
|
||||
});
|
||||
|
@@ -10,6 +10,7 @@ import {
|
||||
RedirectWhenTokenExpiredAction,
|
||||
RefreshTokenSuccessAction,
|
||||
RetrieveAuthenticatedEpersonSuccessAction,
|
||||
RetrieveAuthMethodsErrorAction,
|
||||
RetrieveAuthMethodsSuccessAction,
|
||||
SetRedirectUrlAction
|
||||
} from './auth.actions';
|
||||
@@ -211,14 +212,14 @@ export function authReducer(state: any = initialState, action: AuthActions): Aut
|
||||
case AuthActionTypes.RETRIEVE_AUTH_METHODS_SUCCESS:
|
||||
return Object.assign({}, state, {
|
||||
loading: false,
|
||||
blocking: false,
|
||||
authMethods: (action as RetrieveAuthMethodsSuccessAction).payload
|
||||
blocking: (action as RetrieveAuthMethodsSuccessAction).payload.blocking,
|
||||
authMethods: (action as RetrieveAuthMethodsSuccessAction).payload.authMethods
|
||||
});
|
||||
|
||||
case AuthActionTypes.RETRIEVE_AUTH_METHODS_ERROR:
|
||||
return Object.assign({}, state, {
|
||||
loading: false,
|
||||
blocking: false,
|
||||
blocking: (action as RetrieveAuthMethodsErrorAction).payload,
|
||||
authMethods: [new AuthMethod(AuthMethodType.Password)]
|
||||
});
|
||||
|
||||
|
@@ -35,6 +35,7 @@ import { AppState } from '../../app.reducer';
|
||||
import {
|
||||
CheckAuthenticationTokenAction,
|
||||
ResetAuthenticationMessagesAction,
|
||||
RetrieveAuthMethodsAction,
|
||||
SetRedirectUrlAction
|
||||
} from './auth.actions';
|
||||
import { NativeWindowRef, NativeWindowService } from '../services/window.service';
|
||||
@@ -518,4 +519,13 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a new instance of RetrieveAuthMethodsAction
|
||||
*
|
||||
* @param authStatus The auth status
|
||||
*/
|
||||
getRetrieveAuthMethodsAction(authStatus: AuthStatus): RetrieveAuthMethodsAction {
|
||||
return new RetrieveAuthMethodsAction(authStatus, false);
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -10,6 +10,7 @@ import { AuthService } from './auth.service';
|
||||
import { AuthStatus } from './models/auth-status.model';
|
||||
import { AuthTokenInfo } from './models/auth-token-info.model';
|
||||
import { RemoteData } from '../data/remote-data';
|
||||
import { RetrieveAuthMethodsAction } from './auth.actions';
|
||||
|
||||
/**
|
||||
* The auth service.
|
||||
@@ -60,4 +61,13 @@ export class ServerAuthService extends AuthService {
|
||||
map((rd: RemoteData<AuthStatus>) => Object.assign(new AuthStatus(), rd.payload))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a new instance of RetrieveAuthMethodsAction
|
||||
*
|
||||
* @param authStatus The auth status
|
||||
*/
|
||||
getRetrieveAuthMethodsAction(authStatus: AuthStatus): RetrieveAuthMethodsAction {
|
||||
return new RetrieveAuthMethodsAction(authStatus, true);
|
||||
}
|
||||
}
|
||||
|
10
src/app/core/cache/builders/link.service.spec.ts
vendored
10
src/app/core/cache/builders/link.service.spec.ts
vendored
@@ -102,7 +102,7 @@ describe('LinkService', () => {
|
||||
describe('resolveLink', () => {
|
||||
describe(`when the linkdefinition concerns a single object`, () => {
|
||||
beforeEach(() => {
|
||||
service.resolveLink(testModel, followLink('predecessor', {}, true, true, true, followLink('successor')));
|
||||
service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor')));
|
||||
});
|
||||
it('should call dataservice.findByHref with the correct href and nested links', () => {
|
||||
expect(testDataService.findByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, true, true, followLink('successor'));
|
||||
@@ -116,7 +116,7 @@ describe('LinkService', () => {
|
||||
propertyName: 'predecessor',
|
||||
isList: true
|
||||
});
|
||||
service.resolveLink(testModel, followLink('predecessor', { some: 'options ' } as any, true, true, true, followLink('successor')));
|
||||
service.resolveLink(testModel, followLink('predecessor', { findListOptions: { some: 'options ' } as any }, followLink('successor')));
|
||||
});
|
||||
it('should call dataservice.findAllByHref with the correct href, findListOptions, and nested links', () => {
|
||||
expect(testDataService.findAllByHref).toHaveBeenCalledWith(testModel._links.predecessor.href, { some: 'options ' } as any, true, true, followLink('successor'));
|
||||
@@ -124,7 +124,7 @@ describe('LinkService', () => {
|
||||
});
|
||||
describe('either way', () => {
|
||||
beforeEach(() => {
|
||||
result = service.resolveLink(testModel, followLink('predecessor', {}, true, true, true, followLink('successor')));
|
||||
result = service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor')));
|
||||
});
|
||||
|
||||
it('should call getLinkDefinition with the correct model and link', () => {
|
||||
@@ -149,7 +149,7 @@ describe('LinkService', () => {
|
||||
});
|
||||
it('should throw an error', () => {
|
||||
expect(() => {
|
||||
service.resolveLink(testModel, followLink('predecessor', {}, true, true, true, followLink('successor')));
|
||||
service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor')));
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -160,7 +160,7 @@ describe('LinkService', () => {
|
||||
});
|
||||
it('should throw an error', () => {
|
||||
expect(() => {
|
||||
service.resolveLink(testModel, followLink('predecessor', {}, true, true, true, followLink('successor')));
|
||||
service.resolveLink(testModel, followLink('predecessor', {}, followLink('successor')));
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
9
src/app/core/cache/builders/link.service.ts
vendored
9
src/app/core/cache/builders/link.service.ts
vendored
@@ -39,7 +39,7 @@ export class LinkService {
|
||||
*/
|
||||
public resolveLinks<T extends HALResource>(model: T, ...linksToFollow: FollowLinkConfig<T>[]): T {
|
||||
linksToFollow.forEach((linkToFollow: FollowLinkConfig<T>) => {
|
||||
this.resolveLink(model, linkToFollow);
|
||||
this.resolveLink(model, linkToFollow);
|
||||
});
|
||||
return model;
|
||||
}
|
||||
@@ -55,9 +55,7 @@ export class LinkService {
|
||||
public resolveLinkWithoutAttaching<T extends HALResource, U extends HALResource>(model, linkToFollow: FollowLinkConfig<T>): Observable<RemoteData<U>> {
|
||||
const matchingLinkDef = this.getLinkDefinition(model.constructor, linkToFollow.name);
|
||||
|
||||
if (hasNoValue(matchingLinkDef)) {
|
||||
throw new Error(`followLink('${linkToFollow.name}') was used for a ${model.constructor.name}, but there is no property on ${model.constructor.name} models with an @link() for ${linkToFollow.name}`);
|
||||
} else {
|
||||
if (hasValue(matchingLinkDef)) {
|
||||
const provider = this.getDataServiceFor(matchingLinkDef.resourceType);
|
||||
|
||||
if (hasNoValue(provider)) {
|
||||
@@ -84,7 +82,10 @@ export class LinkService {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
} else if (!linkToFollow.isOptional) {
|
||||
throw new Error(`followLink('${linkToFollow.name}') was used as a required link for a ${model.constructor.name}, but there is no property on ${model.constructor.name} models with an @link() for ${linkToFollow.name}`);
|
||||
}
|
||||
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
|
@@ -523,7 +523,7 @@ describe('RemoteDataBuildService', () => {
|
||||
let paginatedLinksToFollow;
|
||||
beforeEach(() => {
|
||||
paginatedLinksToFollow = [
|
||||
followLink('page', undefined, true, true, true, ...linksToFollow),
|
||||
followLink('page', {}, ...linksToFollow),
|
||||
...linksToFollow
|
||||
];
|
||||
});
|
||||
|
@@ -271,7 +271,7 @@ export class RemoteDataBuildService {
|
||||
* @param linksToFollow List of {@link FollowLinkConfig} that indicate which {@link HALLink}s should be automatically resolved
|
||||
*/
|
||||
buildList<T extends HALResource>(href$: string | Observable<string>, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<PaginatedList<T>>> {
|
||||
return this.buildFromHref<PaginatedList<T>>(href$, followLink('page', undefined, false, true, true, ...linksToFollow));
|
||||
return this.buildFromHref<PaginatedList<T>>(href$, followLink('page', { shouldEmbed: false }, ...linksToFollow));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -162,6 +162,7 @@ import { UsageReport } from './statistics/models/usage-report.model';
|
||||
import { RootDataService } from './data/root-data.service';
|
||||
import { Root } from './data/root.model';
|
||||
import { SearchConfig } from './shared/search/search-filters/search-config.model';
|
||||
import { SequenceService } from './shared/sequence.service';
|
||||
|
||||
/**
|
||||
* When not in production, endpoint responses can be mocked for testing purposes
|
||||
@@ -282,7 +283,8 @@ const PROVIDERS = [
|
||||
FilteredDiscoveryPageResponseParsingService,
|
||||
{ provide: NativeWindowService, useFactory: NativeWindowFactory },
|
||||
VocabularyService,
|
||||
VocabularyTreeviewService
|
||||
VocabularyTreeviewService,
|
||||
SequenceService,
|
||||
];
|
||||
|
||||
/**
|
||||
|
@@ -13,6 +13,7 @@ import {
|
||||
BitstreamFormatRegistryState
|
||||
} from '../+admin/admin-registries/bitstream-formats/bitstream-format.reducers';
|
||||
import { historyReducer, HistoryState } from './history/history.reducer';
|
||||
import { metaTagReducer, MetaTagState } from './metadata/meta-tag.reducer';
|
||||
|
||||
export interface CoreState {
|
||||
'bitstreamFormats': BitstreamFormatRegistryState;
|
||||
@@ -24,6 +25,7 @@ export interface CoreState {
|
||||
'index': MetaIndexState;
|
||||
'auth': AuthState;
|
||||
'json/patch': JsonPatchOperationsState;
|
||||
'metaTag': MetaTagState;
|
||||
'route': RouteState;
|
||||
}
|
||||
|
||||
@@ -37,5 +39,6 @@ export const coreReducers: ActionReducerMap<CoreState> = {
|
||||
'index': indexReducer,
|
||||
'auth': authReducer,
|
||||
'json/patch': jsonPatchOperationsReducer,
|
||||
'metaTag': metaTagReducer,
|
||||
'route': routeReducer
|
||||
};
|
||||
|
@@ -3,7 +3,7 @@ import { Injectable } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { combineLatest as observableCombineLatest, Observable } from 'rxjs';
|
||||
import { map, switchMap, take } from 'rxjs/operators';
|
||||
import { hasValue, isNotEmpty } from '../../shared/empty.util';
|
||||
import { hasValue } from '../../shared/empty.util';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
||||
import { dataService } from '../cache/builders/build-decorators';
|
||||
@@ -18,7 +18,7 @@ import { Item } from '../shared/item.model';
|
||||
import { BundleDataService } from './bundle-data.service';
|
||||
import { DataService } from './data.service';
|
||||
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
|
||||
import { PaginatedList, buildPaginatedList } from './paginated-list.model';
|
||||
import { buildPaginatedList, PaginatedList } from './paginated-list.model';
|
||||
import { RemoteData } from './remote-data';
|
||||
import { FindListOptions, PutRequest } from './request.models';
|
||||
import { RequestService } from './request.service';
|
||||
@@ -28,7 +28,6 @@ import { HttpOptions } from '../dspace-rest/dspace-rest.service';
|
||||
import { sendRequest } from '../shared/operators';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||
import { PageInfo } from '../shared/page-info.model';
|
||||
import { RequestEntryState } from './request.reducer';
|
||||
|
||||
/**
|
||||
* A service to retrieve {@link Bitstream}s from the REST API
|
||||
@@ -75,92 +74,6 @@ export class BitstreamDataService extends DataService<Bitstream> {
|
||||
return this.findAllByHref(bundle._links.bitstreams.href, options, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the thumbnail for the given item
|
||||
* @returns {Observable<RemoteData<{@link Bitstream}>>} the first bitstream in the THUMBNAIL bundle
|
||||
*/
|
||||
// TODO should be implemented rest side. {@link Item} should get a thumbnail link
|
||||
public getThumbnailFor(item: Item): Observable<RemoteData<Bitstream>> {
|
||||
return this.bundleService.findByItemAndName(item, 'THUMBNAIL').pipe(
|
||||
switchMap((bundleRD: RemoteData<Bundle>) => {
|
||||
if (isNotEmpty(bundleRD.payload)) {
|
||||
return this.findAllByBundle(bundleRD.payload, { elementsPerPage: 1 }).pipe(
|
||||
map((bitstreamRD: RemoteData<PaginatedList<Bitstream>>) => {
|
||||
if (hasValue(bitstreamRD.payload) && hasValue(bitstreamRD.payload.page)) {
|
||||
return new RemoteData(
|
||||
bitstreamRD.timeCompleted,
|
||||
bitstreamRD.msToLive,
|
||||
bitstreamRD.lastUpdated,
|
||||
bitstreamRD.state,
|
||||
bitstreamRD.errorMessage,
|
||||
bitstreamRD.payload.page[0],
|
||||
bitstreamRD.statusCode
|
||||
);
|
||||
} else {
|
||||
return bitstreamRD as any;
|
||||
}
|
||||
})
|
||||
);
|
||||
} else {
|
||||
return [bundleRD as any];
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the matching thumbnail for a {@link Bitstream}.
|
||||
*
|
||||
* The {@link Item} is technically redundant, but is available
|
||||
* in all current use cases, and having it simplifies this method
|
||||
*
|
||||
* @param item The {@link Item} the {@link Bitstream} and its thumbnail are a part of
|
||||
* @param bitstreamInOriginal The original {@link Bitstream} to find the thumbnail for
|
||||
*/
|
||||
// TODO should be implemented rest side
|
||||
public getMatchingThumbnail(item: Item, bitstreamInOriginal: Bitstream): Observable<RemoteData<Bitstream>> {
|
||||
return this.bundleService.findByItemAndName(item, 'THUMBNAIL').pipe(
|
||||
switchMap((bundleRD: RemoteData<Bundle>) => {
|
||||
if (isNotEmpty(bundleRD.payload)) {
|
||||
return this.findAllByBundle(bundleRD.payload, { elementsPerPage: 9999 }).pipe(
|
||||
map((bitstreamRD: RemoteData<PaginatedList<Bitstream>>) => {
|
||||
if (hasValue(bitstreamRD.payload) && hasValue(bitstreamRD.payload.page)) {
|
||||
const matchingThumbnail = bitstreamRD.payload.page.find((thumbnail: Bitstream) =>
|
||||
thumbnail.name.startsWith(bitstreamInOriginal.name)
|
||||
);
|
||||
if (hasValue(matchingThumbnail)) {
|
||||
return new RemoteData(
|
||||
bitstreamRD.timeCompleted,
|
||||
bitstreamRD.msToLive,
|
||||
bitstreamRD.lastUpdated,
|
||||
bitstreamRD.state,
|
||||
bitstreamRD.errorMessage,
|
||||
matchingThumbnail,
|
||||
bitstreamRD.statusCode
|
||||
);
|
||||
} else {
|
||||
return new RemoteData(
|
||||
bitstreamRD.timeCompleted,
|
||||
bitstreamRD.msToLive,
|
||||
bitstreamRD.lastUpdated,
|
||||
RequestEntryState.Error,
|
||||
'No matching thumbnail found',
|
||||
undefined,
|
||||
404
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return bitstreamRD as any;
|
||||
}
|
||||
})
|
||||
);
|
||||
} else {
|
||||
return [bundleRD as any];
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve all {@link Bitstream}s in a certain {@link Bundle}.
|
||||
*
|
||||
|
@@ -233,7 +233,7 @@ describe('DataService', () => {
|
||||
const config: FindListOptions = Object.assign(new FindListOptions(), {
|
||||
elementsPerPage: 5
|
||||
});
|
||||
(service as any).getFindAllHref({}, null, followLink('bundles', config, true, true, true)).subscribe((value) => {
|
||||
(service as any).getFindAllHref({}, null, followLink('bundles', { findListOptions: config })).subscribe((value) => {
|
||||
expect(value).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -253,7 +253,7 @@ describe('DataService', () => {
|
||||
elementsPerPage: 2
|
||||
});
|
||||
|
||||
(service as any).getFindAllHref({}, null, followLink('bundles'), followLink('owningCollection', config, true, true, true), followLink('templateItemOf')).subscribe((value) => {
|
||||
(service as any).getFindAllHref({}, null, followLink('bundles'), followLink('owningCollection', { findListOptions: config }), followLink('templateItemOf')).subscribe((value) => {
|
||||
expect(value).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -261,7 +261,13 @@ describe('DataService', () => {
|
||||
it('should not include linksToFollow with shouldEmbed = false', () => {
|
||||
const expected = `${endpoint}?embed=templateItemOf`;
|
||||
|
||||
(service as any).getFindAllHref({}, null, followLink('bundles', undefined, false), followLink('owningCollection', undefined, false), followLink('templateItemOf')).subscribe((value) => {
|
||||
(service as any).getFindAllHref(
|
||||
{},
|
||||
null,
|
||||
followLink('bundles', { shouldEmbed: false }),
|
||||
followLink('owningCollection', { shouldEmbed: false }),
|
||||
followLink('templateItemOf')
|
||||
).subscribe((value) => {
|
||||
expect(value).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -269,7 +275,7 @@ describe('DataService', () => {
|
||||
it('should include nested linksToFollow 3lvl', () => {
|
||||
const expected = `${endpoint}?embed=owningCollection/itemtemplate/relationships`;
|
||||
|
||||
(service as any).getFindAllHref({}, null, followLink('owningCollection', undefined, true, true, true, followLink('itemtemplate', undefined, true, true, true, followLink('relationships')))).subscribe((value) => {
|
||||
(service as any).getFindAllHref({}, null, followLink('owningCollection', {}, followLink('itemtemplate', {}, followLink('relationships')))).subscribe((value) => {
|
||||
expect(value).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -279,7 +285,7 @@ describe('DataService', () => {
|
||||
const config: FindListOptions = Object.assign(new FindListOptions(), {
|
||||
elementsPerPage: 4
|
||||
});
|
||||
(service as any).getFindAllHref({}, null, followLink('owningCollection', undefined, true, true, true, followLink('itemtemplate', config, true, true, true))).subscribe((value) => {
|
||||
(service as any).getFindAllHref({}, null, followLink('owningCollection', {}, followLink('itemtemplate', { findListOptions: config }))).subscribe((value) => {
|
||||
expect(value).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -308,13 +314,19 @@ describe('DataService', () => {
|
||||
|
||||
it('should not include linksToFollow with shouldEmbed = false', () => {
|
||||
const expected = `${endpointMock}/${resourceIdMock}?embed=templateItemOf`;
|
||||
const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('bundles', undefined, false), followLink('owningCollection', undefined, false), followLink('templateItemOf'));
|
||||
const result = (service as any).getIDHref(
|
||||
endpointMock,
|
||||
resourceIdMock,
|
||||
followLink('bundles', { shouldEmbed: false }),
|
||||
followLink('owningCollection', { shouldEmbed: false }),
|
||||
followLink('templateItemOf')
|
||||
);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should include nested linksToFollow 3lvl', () => {
|
||||
const expected = `${endpointMock}/${resourceIdMock}?embed=owningCollection/itemtemplate/relationships`;
|
||||
const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('owningCollection', undefined, true, true, true,followLink('itemtemplate', undefined, true, true, true, followLink('relationships'))));
|
||||
const result = (service as any).getIDHref(endpointMock, resourceIdMock, followLink('owningCollection', {}, followLink('itemtemplate', {}, followLink('relationships'))));
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
@@ -119,7 +119,7 @@ describe('DsoRedirectDataService', () => {
|
||||
});
|
||||
it('should navigate to entities route with the corresponding entity type', () => {
|
||||
remoteData.payload.type = 'item';
|
||||
remoteData.payload.metadata = {
|
||||
remoteData.payload.metadata = {
|
||||
'dspace.entity.type': [
|
||||
{
|
||||
language: 'en_US',
|
||||
@@ -174,13 +174,29 @@ describe('DsoRedirectDataService', () => {
|
||||
|
||||
it('should not include linksToFollow with shouldEmbed = false', () => {
|
||||
const expected = `${requestUUIDURL}&embed=templateItemOf`;
|
||||
const result = (service as any).getIDHref(pidLink, dsoUUID, followLink('bundles', undefined, false), followLink('owningCollection', undefined, false), followLink('templateItemOf'));
|
||||
const result = (service as any).getIDHref(
|
||||
pidLink,
|
||||
dsoUUID,
|
||||
followLink('bundles', { shouldEmbed: false }),
|
||||
followLink('owningCollection', { shouldEmbed: false }),
|
||||
followLink('templateItemOf')
|
||||
);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should include nested linksToFollow 3lvl', () => {
|
||||
const expected = `${requestUUIDURL}&embed=owningCollection/itemtemplate/relationships`;
|
||||
const result = (service as any).getIDHref(pidLink, dsoUUID, followLink('owningCollection', undefined, true, true, true, followLink('itemtemplate', undefined, true, true, true, followLink('relationships'))));
|
||||
const result = (service as any).getIDHref(
|
||||
pidLink,
|
||||
dsoUUID,
|
||||
followLink('owningCollection',
|
||||
{},
|
||||
followLink('itemtemplate',
|
||||
{},
|
||||
followLink('relationships')
|
||||
)
|
||||
)
|
||||
);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
@@ -10,6 +10,7 @@ export enum FeatureID {
|
||||
ReinstateItem = 'reinstateItem',
|
||||
EPersonRegistration = 'epersonRegistration',
|
||||
CanManageGroups = 'canManageGroups',
|
||||
CanManageGroup = 'canManageGroup',
|
||||
IsCollectionAdmin = 'isCollectionAdmin',
|
||||
IsCommunityAdmin = 'isCommunityAdmin',
|
||||
CanDownload = 'canDownload',
|
||||
|
@@ -23,14 +23,7 @@ import { DataService } from './data.service';
|
||||
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
|
||||
import { PaginatedList } from './paginated-list.model';
|
||||
import { RemoteData } from './remote-data';
|
||||
import {
|
||||
DeleteRequest,
|
||||
FindListOptions,
|
||||
GetRequest,
|
||||
PostRequest,
|
||||
PutRequest,
|
||||
RestRequest
|
||||
} from './request.models';
|
||||
import { DeleteRequest, FindListOptions, GetRequest, PostRequest, PutRequest, RestRequest } from './request.models';
|
||||
import { RequestService } from './request.service';
|
||||
import { PaginatedSearchOptions } from '../../shared/search/paginated-search-options.model';
|
||||
import { Bundle } from '../shared/bundle.model';
|
||||
@@ -38,6 +31,9 @@ import { MetadataMap } from '../shared/metadata.models';
|
||||
import { BundleDataService } from './bundle-data.service';
|
||||
import { Operation } from 'fast-json-patch';
|
||||
import { NoContent } from '../shared/NoContent.model';
|
||||
import { GenericConstructor } from '../shared/generic-constructor';
|
||||
import { ResponseParsingService } from './parsing.service';
|
||||
import { StatusCodeOnlyResponseParsingService } from './status-code-only-response-parsing.service';
|
||||
|
||||
@Injectable()
|
||||
@dataService(ITEM)
|
||||
@@ -229,7 +225,7 @@ export class ItemDataService extends DataService<Item> {
|
||||
* @param itemId
|
||||
* @param collection
|
||||
*/
|
||||
public moveToCollection(itemId: string, collection: Collection): Observable<RemoteData<Collection>> {
|
||||
public moveToCollection(itemId: string, collection: Collection): Observable<RemoteData<any>> {
|
||||
const options: HttpOptions = Object.create({});
|
||||
let headers = new HttpHeaders();
|
||||
headers = headers.append('Content-Type', 'text/uri-list');
|
||||
@@ -242,9 +238,17 @@ export class ItemDataService extends DataService<Item> {
|
||||
find((href: string) => hasValue(href)),
|
||||
map((href: string) => {
|
||||
const request = new PutRequest(requestId, href, collection._links.self.href, options);
|
||||
this.requestService.send(request);
|
||||
Object.assign(request, {
|
||||
// TODO: for now, the move Item endpoint returns a malformed collection -- only look at the status code
|
||||
getResponseParser(): GenericConstructor<ResponseParsingService> {
|
||||
return StatusCodeOnlyResponseParsingService;
|
||||
}
|
||||
});
|
||||
return request;
|
||||
})
|
||||
).subscribe();
|
||||
).subscribe((request) => {
|
||||
this.requestService.send(request);
|
||||
});
|
||||
|
||||
return this.rdbService.buildFromRequestUUID(requestId);
|
||||
}
|
||||
|
@@ -17,6 +17,11 @@ export class GroupDtoModel {
|
||||
*/
|
||||
public ableToDelete: boolean;
|
||||
|
||||
/**
|
||||
* Whether or not the current user is able to edit the linked group
|
||||
*/
|
||||
public ableToEdit: boolean;
|
||||
|
||||
/**
|
||||
* List of subgroups of this group
|
||||
*/
|
||||
|
@@ -9,7 +9,7 @@ import {
|
||||
import { JsonPatchOperationPathObject } from './json-patch-operation-path-combiner';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { hasNoValue, hasValue, isEmpty, isNotEmpty } from '../../../shared/empty.util';
|
||||
import { dateToISOFormat } from '../../../shared/date.util';
|
||||
import { dateToISOFormat, dateToString, isNgbDateStruct } from '../../../shared/date.util';
|
||||
import { VocabularyEntry } from '../../submission/vocabularies/models/vocabulary-entry.model';
|
||||
import { FormFieldMetadataValueObject } from '../../../shared/form/builder/models/form-field-metadata-value.model';
|
||||
import { FormFieldLanguageValueObject } from '../../../shared/form/builder/models/form-field-language-value.model';
|
||||
@@ -136,6 +136,8 @@ export class JsonPatchOperationsBuilder {
|
||||
operationValue = new FormFieldMetadataValueObject(value.value, value.language);
|
||||
} else if (value.hasOwnProperty('authority')) {
|
||||
operationValue = new FormFieldMetadataValueObject(value.value, value.language, value.authority);
|
||||
} else if (isNgbDateStruct(value)) {
|
||||
operationValue = new FormFieldMetadataValueObject(dateToString(value));
|
||||
} else if (value.hasOwnProperty('value')) {
|
||||
operationValue = new FormFieldMetadataValueObject(value.value);
|
||||
} else {
|
||||
|
@@ -20,6 +20,7 @@ export const JsonPatchOperationsActionTypes = {
|
||||
ROLLBACK_JSON_PATCH_OPERATIONS: type('dspace/core/patch/ROLLBACK_JSON_PATCH_OPERATIONS'),
|
||||
FLUSH_JSON_PATCH_OPERATIONS: type('dspace/core/patch/FLUSH_JSON_PATCH_OPERATIONS'),
|
||||
START_TRANSACTION_JSON_PATCH_OPERATIONS: type('dspace/core/patch/START_TRANSACTION_JSON_PATCH_OPERATIONS'),
|
||||
DELETE_PENDING_JSON_PATCH_OPERATIONS: type('dspace/core/patch/DELETE_PENDING_JSON_PATCH_OPERATIONS'),
|
||||
};
|
||||
|
||||
/* tslint:disable:max-classes-per-file */
|
||||
@@ -261,6 +262,13 @@ export class NewPatchReplaceOperationAction implements Action {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An ngrx action to delete all pending JSON Patch Operations.
|
||||
*/
|
||||
export class DeletePendingJsonPatchOperationsAction implements Action {
|
||||
type = JsonPatchOperationsActionTypes.DELETE_PENDING_JSON_PATCH_OPERATIONS;
|
||||
}
|
||||
|
||||
/* tslint:enable:max-classes-per-file */
|
||||
|
||||
/**
|
||||
@@ -276,4 +284,5 @@ export type PatchOperationsActions
|
||||
| NewPatchRemoveOperationAction
|
||||
| NewPatchReplaceOperationAction
|
||||
| RollbacktPatchOperationsAction
|
||||
| StartTransactionPatchOperationsAction;
|
||||
| StartTransactionPatchOperationsAction
|
||||
| DeletePendingJsonPatchOperationsAction;
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import * as deepFreeze from 'deep-freeze';
|
||||
|
||||
import {
|
||||
CommitPatchOperationsAction,
|
||||
CommitPatchOperationsAction, DeletePendingJsonPatchOperationsAction,
|
||||
FlushPatchOperationsAction,
|
||||
NewPatchAddOperationAction,
|
||||
NewPatchRemoveOperationAction,
|
||||
@@ -323,4 +323,19 @@ describe('jsonPatchOperationsReducer test suite', () => {
|
||||
|
||||
});
|
||||
|
||||
describe('When DeletePendingJsonPatchOperationsAction has been dispatched', () => {
|
||||
it('should set set the JsonPatchOperationsState to null ', () => {
|
||||
const action = new DeletePendingJsonPatchOperationsAction();
|
||||
initState = Object.assign({}, testState, {
|
||||
[testJsonPatchResourceType]: Object.assign({}, testState[testJsonPatchResourceType], {
|
||||
transactionStartTime: startTimestamp,
|
||||
commitPending: true
|
||||
})
|
||||
});
|
||||
const newState = jsonPatchOperationsReducer(initState, action);
|
||||
|
||||
expect(newState).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
@@ -11,7 +11,8 @@ import {
|
||||
NewPatchReplaceOperationAction,
|
||||
CommitPatchOperationsAction,
|
||||
StartTransactionPatchOperationsAction,
|
||||
RollbacktPatchOperationsAction
|
||||
RollbacktPatchOperationsAction,
|
||||
DeletePendingJsonPatchOperationsAction
|
||||
} from './json-patch-operations.actions';
|
||||
import { JsonPatchOperationModel, JsonPatchOperationType } from './json-patch.model';
|
||||
|
||||
@@ -101,6 +102,10 @@ export function jsonPatchOperationsReducer(state = initialState, action: PatchOp
|
||||
return startTransactionPatchOperations(state, action as StartTransactionPatchOperationsAction);
|
||||
}
|
||||
|
||||
case JsonPatchOperationsActionTypes.DELETE_PENDING_JSON_PATCH_OPERATIONS: {
|
||||
return deletePendingOperations(state, action as DeletePendingJsonPatchOperationsAction);
|
||||
}
|
||||
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
@@ -178,6 +183,20 @@ function rollbackOperations(state: JsonPatchOperationsState, action: RollbacktPa
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the JsonPatchOperationsState to its initial value.
|
||||
*
|
||||
* @param state
|
||||
* the current state
|
||||
* @param action
|
||||
* an DeletePendingJsonPatchOperationsAction
|
||||
* @return JsonPatchOperationsState
|
||||
* the new state.
|
||||
*/
|
||||
function deletePendingOperations(state: JsonPatchOperationsState, action: DeletePendingJsonPatchOperationsAction): JsonPatchOperationsState {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new JSON patch operation list.
|
||||
*
|
||||
|
@@ -17,6 +17,7 @@ import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||
import { JsonPatchOperationsEntry, JsonPatchOperationsResourceEntry } from './json-patch-operations.reducer';
|
||||
import {
|
||||
CommitPatchOperationsAction,
|
||||
DeletePendingJsonPatchOperationsAction,
|
||||
RollbacktPatchOperationsAction,
|
||||
StartTransactionPatchOperationsAction
|
||||
} from './json-patch-operations.actions';
|
||||
@@ -288,4 +289,19 @@ describe('JsonPatchOperationsService test suite', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('deletePendingJsonPatchOperations', () => {
|
||||
beforeEach(() => {
|
||||
store.dispatch.and.callFake(() => { /* */ });
|
||||
});
|
||||
|
||||
it('should dispatch a new DeletePendingJsonPatchOperationsAction', () => {
|
||||
|
||||
const expectedAction = new DeletePendingJsonPatchOperationsAction();
|
||||
scheduler.schedule(() => service.deletePendingJsonPatchOperations());
|
||||
scheduler.flush();
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledWith(expectedAction);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
@@ -10,7 +10,7 @@ import { CoreState } from '../core.reducers';
|
||||
import { jsonPatchOperationsByResourceType } from './selectors';
|
||||
import { JsonPatchOperationsResourceEntry } from './json-patch-operations.reducer';
|
||||
import {
|
||||
CommitPatchOperationsAction,
|
||||
CommitPatchOperationsAction, DeletePendingJsonPatchOperationsAction,
|
||||
RollbacktPatchOperationsAction,
|
||||
StartTransactionPatchOperationsAction
|
||||
} from './json-patch-operations.actions';
|
||||
@@ -105,6 +105,13 @@ export abstract class JsonPatchOperationsService<ResponseDefinitionDomain, Patch
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an action to delete all pending JSON patch Operations.
|
||||
*/
|
||||
public deletePendingJsonPatchOperations() {
|
||||
this.store.dispatch(new DeletePendingJsonPatchOperationsAction());
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an instance for RestRequest class
|
||||
*
|
||||
|
23
src/app/core/metadata/meta-tag.actions.ts
Normal file
23
src/app/core/metadata/meta-tag.actions.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { type } from '../../shared/ngrx/type';
|
||||
import { Action } from '@ngrx/store';
|
||||
|
||||
// tslint:disable:max-classes-per-file
|
||||
export const MetaTagTypes = {
|
||||
ADD: type('dspace/meta-tag/ADD'),
|
||||
CLEAR: type('dspace/meta-tag/CLEAR')
|
||||
};
|
||||
|
||||
export class AddMetaTagAction implements Action {
|
||||
type = MetaTagTypes.ADD;
|
||||
payload: string;
|
||||
|
||||
constructor(property: string) {
|
||||
this.payload = property;
|
||||
}
|
||||
}
|
||||
|
||||
export class ClearMetaTagAction implements Action {
|
||||
type = MetaTagTypes.CLEAR;
|
||||
}
|
||||
|
||||
export type MetaTagAction = AddMetaTagAction | ClearMetaTagAction;
|
50
src/app/core/metadata/meta-tag.reducer.spec.ts
Normal file
50
src/app/core/metadata/meta-tag.reducer.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* The contents of this file are subject to the license and copyright
|
||||
* detailed in the LICENSE and NOTICE files at the root of the source
|
||||
* tree and available online at
|
||||
*
|
||||
* http://www.dspace.org/license/
|
||||
*/
|
||||
|
||||
import { metaTagReducer } from './meta-tag.reducer';
|
||||
import { AddMetaTagAction, ClearMetaTagAction } from './meta-tag.actions';
|
||||
|
||||
const nullAction = { type: null };
|
||||
|
||||
describe('metaTagReducer', () => {
|
||||
it('should start with an empty array', () => {
|
||||
const state0 = metaTagReducer(undefined, nullAction);
|
||||
expect(state0.tagsInUse).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return the current state on invalid action', () => {
|
||||
const state0 = {
|
||||
tagsInUse: ['foo', 'bar'],
|
||||
};
|
||||
|
||||
const state1 = metaTagReducer(state0, nullAction);
|
||||
expect(state1).toEqual(state0);
|
||||
});
|
||||
|
||||
it('should add tags on AddMetaTagAction', () => {
|
||||
const state0 = {
|
||||
tagsInUse: ['foo'],
|
||||
};
|
||||
|
||||
const state1 = metaTagReducer(state0, new AddMetaTagAction('bar'));
|
||||
const state2 = metaTagReducer(state1, new AddMetaTagAction('baz'));
|
||||
|
||||
expect(state1.tagsInUse).toEqual(['foo', 'bar']);
|
||||
expect(state2.tagsInUse).toEqual(['foo', 'bar', 'baz']);
|
||||
});
|
||||
|
||||
it('should clear tags on ClearMetaTagAction', () => {
|
||||
const state0 = {
|
||||
tagsInUse: ['foo', 'bar'],
|
||||
};
|
||||
|
||||
const state1 = metaTagReducer(state0, new ClearMetaTagAction());
|
||||
|
||||
expect(state1.tagsInUse).toEqual([]);
|
||||
});
|
||||
});
|
38
src/app/core/metadata/meta-tag.reducer.ts
Normal file
38
src/app/core/metadata/meta-tag.reducer.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
MetaTagAction,
|
||||
MetaTagTypes,
|
||||
AddMetaTagAction,
|
||||
ClearMetaTagAction,
|
||||
} from './meta-tag.actions';
|
||||
|
||||
export interface MetaTagState {
|
||||
tagsInUse: string[];
|
||||
}
|
||||
|
||||
const initialstate: MetaTagState = {
|
||||
tagsInUse: []
|
||||
};
|
||||
|
||||
export const metaTagReducer = (state: MetaTagState = initialstate, action: MetaTagAction): MetaTagState => {
|
||||
switch (action.type) {
|
||||
case MetaTagTypes.ADD: {
|
||||
return addMetaTag(state, action as AddMetaTagAction);
|
||||
}
|
||||
case MetaTagTypes.CLEAR: {
|
||||
return clearMetaTags(state, action as ClearMetaTagAction);
|
||||
}
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addMetaTag = (state: MetaTagState, action: AddMetaTagAction): MetaTagState => {
|
||||
return {
|
||||
tagsInUse: [...state.tagsInUse, action.payload]
|
||||
};
|
||||
};
|
||||
|
||||
const clearMetaTags = (state: MetaTagState, action: ClearMetaTagAction): MetaTagState => {
|
||||
return Object.assign({}, initialstate);
|
||||
};
|
@@ -1,82 +1,28 @@
|
||||
import { CommonModule, Location } from '@angular/common';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
|
||||
import { Meta, MetaDefinition, Title } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { RouterTestingModule } from '@angular/router/testing';
|
||||
import { fakeAsync, tick } from '@angular/core/testing';
|
||||
import { Meta, Title } from '@angular/platform-browser';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
|
||||
import { Store, StoreModule } from '@ngrx/store';
|
||||
|
||||
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { EmptyError, Observable, of } from 'rxjs';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { Observable, of } from 'rxjs';
|
||||
|
||||
import { RemoteData } from '../data/remote-data';
|
||||
import { Item } from '../shared/item.model';
|
||||
|
||||
import {
|
||||
ItemMock,
|
||||
MockBitstream1,
|
||||
MockBitstream2,
|
||||
MockBitstreamFormat1,
|
||||
MockBitstreamFormat2
|
||||
} from '../../shared/mocks/item.mock';
|
||||
import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock';
|
||||
import { NotificationsService } from '../../shared/notifications/notifications.service';
|
||||
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||
import { FollowLinkConfig } from '../../shared/utils/follow-link-config.model';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { BrowseService } from '../browse/browse.service';
|
||||
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
|
||||
import { ObjectCacheService } from '../cache/object-cache.service';
|
||||
|
||||
import { CoreState } from '../core.reducers';
|
||||
import { BitstreamDataService } from '../data/bitstream-data.service';
|
||||
import { BitstreamFormatDataService } from '../data/bitstream-format-data.service';
|
||||
import { CommunityDataService } from '../data/community-data.service';
|
||||
import { DefaultChangeAnalyzer } from '../data/default-change-analyzer.service';
|
||||
import { DSOChangeAnalyzer } from '../data/dso-change-analyzer.service';
|
||||
|
||||
import { ItemDataService } from '../data/item-data.service';
|
||||
import { buildPaginatedList, PaginatedList } from '../data/paginated-list.model';
|
||||
import { FindListOptions } from '../data/request.models';
|
||||
import { RequestService } from '../data/request.service';
|
||||
import { BitstreamFormat } from '../shared/bitstream-format.model';
|
||||
import { ItemMock, MockBitstream1, MockBitstream3 } from '../../shared/mocks/item.mock';
|
||||
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
|
||||
import { PaginatedList } from '../data/paginated-list.model';
|
||||
import { Bitstream } from '../shared/bitstream.model';
|
||||
import { HALEndpointService } from '../shared/hal-endpoint.service';
|
||||
import { MetadataValue } from '../shared/metadata.models';
|
||||
import { PageInfo } from '../shared/page-info.model';
|
||||
import { UUIDService } from '../shared/uuid.service';
|
||||
|
||||
import { MetadataService } from './metadata.service';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { storeModuleConfig } from '../../app.reducer';
|
||||
import { HardRedirectService } from '../services/hard-redirect.service';
|
||||
import { URLCombiner } from '../url-combiner/url-combiner';
|
||||
import { RootDataService } from '../data/root-data.service';
|
||||
import { Root } from '../data/root.model';
|
||||
|
||||
/* tslint:disable:max-classes-per-file */
|
||||
@Component({
|
||||
template: `
|
||||
<router-outlet></router-outlet>`
|
||||
})
|
||||
class TestComponent {
|
||||
constructor(private metadata: MetadataService) {
|
||||
metadata.listenForRouteChange();
|
||||
}
|
||||
}
|
||||
|
||||
@Component({ template: '' })
|
||||
class DummyItemComponent {
|
||||
constructor(private route: ActivatedRoute, private items: ItemDataService, private metadata: MetadataService) {
|
||||
this.route.params.subscribe((params) => {
|
||||
this.metadata.processRemoteData(this.items.findById(params.id));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* tslint:enable:max-classes-per-file */
|
||||
import { Bundle } from '../shared/bundle.model';
|
||||
import { createPaginatedList } from '../../shared/testing/utils.test';
|
||||
import { getMockTranslateService } from '../../shared/mocks/translate.service.mock';
|
||||
import { DSONameService } from '../breadcrumbs/dso-name.service';
|
||||
import { HardRedirectService } from '../services/hard-redirect.service';
|
||||
import { getMockStore } from '@ngrx/store/testing';
|
||||
import { AddMetaTagAction, ClearMetaTagAction } from './meta-tag.actions';
|
||||
|
||||
describe('MetadataService', () => {
|
||||
let metadataService: MetadataService;
|
||||
@@ -85,188 +31,339 @@ describe('MetadataService', () => {
|
||||
|
||||
let title: Title;
|
||||
|
||||
let store: Store<CoreState>;
|
||||
let dsoNameService: DSONameService;
|
||||
|
||||
let objectCacheService: ObjectCacheService;
|
||||
let requestService: RequestService;
|
||||
let uuidService: UUIDService;
|
||||
let remoteDataBuildService: RemoteDataBuildService;
|
||||
let itemDataService: ItemDataService;
|
||||
let authService: AuthService;
|
||||
let bundleDataService;
|
||||
let bitstreamDataService;
|
||||
let rootService: RootDataService;
|
||||
let translateService: TranslateService;
|
||||
let hardRedirectService: HardRedirectService;
|
||||
|
||||
let location: Location;
|
||||
let router: Router;
|
||||
let fixture: ComponentFixture<TestComponent>;
|
||||
let store;
|
||||
|
||||
const initialState = { 'core': { metaTag: { tagsInUse: ['title', 'description'] }}};
|
||||
|
||||
let tagStore: Map<string, MetaDefinition[]>;
|
||||
|
||||
beforeEach(() => {
|
||||
rootService = jasmine.createSpyObj({
|
||||
findRoot: createSuccessfulRemoteDataObject$({ dspaceVersion: 'mock-dspace-version' })
|
||||
});
|
||||
bitstreamDataService = jasmine.createSpyObj({
|
||||
findAllByHref: createSuccessfulRemoteDataObject$(createPaginatedList([MockBitstream3]))
|
||||
});
|
||||
bundleDataService = jasmine.createSpyObj({
|
||||
findByItemAndName: mockBundleRD$([MockBitstream3])
|
||||
});
|
||||
translateService = getMockTranslateService();
|
||||
meta = jasmine.createSpyObj('meta', {
|
||||
addTag: {},
|
||||
removeTag: {}
|
||||
});
|
||||
title = jasmine.createSpyObj({
|
||||
setTitle: {}
|
||||
});
|
||||
dsoNameService = jasmine.createSpyObj({
|
||||
getName: ItemMock.firstMetadataValue('dc.title')
|
||||
});
|
||||
router = {
|
||||
url: '/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357',
|
||||
events: of(new NavigationEnd(1, '', '')),
|
||||
routerState: {
|
||||
root: {}
|
||||
}
|
||||
} as any as Router;
|
||||
hardRedirectService = jasmine.createSpyObj( {
|
||||
getRequestOrigin: 'https://request.org',
|
||||
});
|
||||
|
||||
store = new Store<CoreState>(undefined, undefined, undefined);
|
||||
// @ts-ignore
|
||||
store = getMockStore({ initialState });
|
||||
spyOn(store, 'dispatch');
|
||||
|
||||
objectCacheService = new ObjectCacheService(store, undefined);
|
||||
uuidService = new UUIDService();
|
||||
requestService = new RequestService(objectCacheService, uuidService, store, undefined);
|
||||
remoteDataBuildService = new RemoteDataBuildService(objectCacheService, undefined, requestService);
|
||||
const mockBitstreamDataService = {
|
||||
findAllByItemAndBundleName(item: Item, bundleName: string, options?: FindListOptions, ...linksToFollow: FollowLinkConfig<Bitstream>[]): Observable<RemoteData<PaginatedList<Bitstream>>> {
|
||||
if (item.equals(ItemMock)) {
|
||||
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), [MockBitstream1, MockBitstream2]));
|
||||
} else {
|
||||
return createSuccessfulRemoteDataObject$(buildPaginatedList(new PageInfo(), []));
|
||||
}
|
||||
},
|
||||
};
|
||||
const mockBitstreamFormatDataService = {
|
||||
findByBitstream(bitstream: Bitstream): Observable<RemoteData<BitstreamFormat>> {
|
||||
switch (bitstream) {
|
||||
case MockBitstream1:
|
||||
return createSuccessfulRemoteDataObject$(MockBitstreamFormat1);
|
||||
break;
|
||||
case MockBitstream2:
|
||||
return createSuccessfulRemoteDataObject$(MockBitstreamFormat2);
|
||||
break;
|
||||
default:
|
||||
return createSuccessfulRemoteDataObject$(new BitstreamFormat());
|
||||
}
|
||||
}
|
||||
};
|
||||
rootService = jasmine.createSpyObj('rootService', {
|
||||
findRoot: createSuccessfulRemoteDataObject$(Object.assign(new Root(), {
|
||||
dspaceVersion: 'mock-dspace-version'
|
||||
}))
|
||||
});
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
StoreModule.forRoot({}, storeModuleConfig),
|
||||
TranslateModule.forRoot({
|
||||
loader: {
|
||||
provide: TranslateLoader,
|
||||
useClass: TranslateLoaderMock
|
||||
}
|
||||
}),
|
||||
RouterTestingModule.withRoutes([
|
||||
{ path: 'items/:id', component: DummyItemComponent, pathMatch: 'full' },
|
||||
{
|
||||
path: 'other',
|
||||
component: DummyItemComponent,
|
||||
pathMatch: 'full',
|
||||
data: { title: 'Dummy Title', description: 'This is a dummy item component for testing!' }
|
||||
}
|
||||
])
|
||||
],
|
||||
declarations: [
|
||||
TestComponent,
|
||||
DummyItemComponent
|
||||
],
|
||||
providers: [
|
||||
{ provide: ObjectCacheService, useValue: objectCacheService },
|
||||
{ provide: RequestService, useValue: requestService },
|
||||
{ provide: RemoteDataBuildService, useValue: remoteDataBuildService },
|
||||
{ provide: HALEndpointService, useValue: {} },
|
||||
{ provide: AuthService, useValue: {} },
|
||||
{ provide: NotificationsService, useValue: {} },
|
||||
{ provide: HttpClient, useValue: {} },
|
||||
{ provide: DSOChangeAnalyzer, useValue: {} },
|
||||
{ provide: CommunityDataService, useValue: {} },
|
||||
{ provide: DefaultChangeAnalyzer, useValue: {} },
|
||||
{ provide: BitstreamFormatDataService, useValue: mockBitstreamFormatDataService },
|
||||
{ provide: BitstreamDataService, useValue: mockBitstreamDataService },
|
||||
{ provide: RootDataService, useValue: rootService },
|
||||
Meta,
|
||||
Title,
|
||||
// tslint:disable-next-line:no-empty
|
||||
{ provide: ItemDataService, useValue: { findById: () => {} } },
|
||||
BrowseService,
|
||||
MetadataService
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
});
|
||||
meta = TestBed.inject(Meta);
|
||||
title = TestBed.inject(Title);
|
||||
itemDataService = TestBed.inject(ItemDataService);
|
||||
metadataService = TestBed.inject(MetadataService);
|
||||
authService = TestBed.inject(AuthService);
|
||||
translateService = TestBed.inject(TranslateService);
|
||||
|
||||
router = TestBed.inject(Router);
|
||||
location = TestBed.inject(Location);
|
||||
|
||||
fixture = TestBed.createComponent(TestComponent);
|
||||
|
||||
tagStore = metadataService.getTagStore();
|
||||
metadataService = new MetadataService(
|
||||
router,
|
||||
translateService,
|
||||
meta,
|
||||
title,
|
||||
dsoNameService,
|
||||
bundleDataService,
|
||||
bitstreamDataService,
|
||||
undefined,
|
||||
rootService,
|
||||
store,
|
||||
hardRedirectService
|
||||
);
|
||||
});
|
||||
|
||||
it('items page should set meta tags', fakeAsync(() => {
|
||||
spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(ItemMock));
|
||||
router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']);
|
||||
(metadataService as any).processRouteChange({
|
||||
data: {
|
||||
value: {
|
||||
dso: createSuccessfulRemoteDataObject(ItemMock),
|
||||
}
|
||||
}
|
||||
});
|
||||
tick();
|
||||
expect(title.getTitle()).toEqual('Test PowerPoint Document');
|
||||
expect(tagStore.get('citation_title')[0].content).toEqual('Test PowerPoint Document');
|
||||
expect(tagStore.get('citation_author')[0].content).toEqual('Doe, Jane');
|
||||
expect(tagStore.get('citation_date')[0].content).toEqual('1650-06-26');
|
||||
expect(tagStore.get('citation_issn')[0].content).toEqual('123456789');
|
||||
expect(tagStore.get('citation_language')[0].content).toEqual('en');
|
||||
expect(tagStore.get('citation_keywords')[0].content).toEqual('keyword1; keyword2; keyword3');
|
||||
expect(title.setTitle).toHaveBeenCalledWith('Test PowerPoint Document');
|
||||
expect(meta.addTag).toHaveBeenCalledWith({
|
||||
property: 'citation_title',
|
||||
content: 'Test PowerPoint Document'
|
||||
});
|
||||
expect(meta.addTag).toHaveBeenCalledWith({ property: 'citation_author', content: 'Doe, Jane' });
|
||||
expect(meta.addTag).toHaveBeenCalledWith({
|
||||
property: 'citation_publication_date',
|
||||
content: '1650-06-26'
|
||||
});
|
||||
expect(meta.addTag).toHaveBeenCalledWith({ property: 'citation_issn', content: '123456789' });
|
||||
expect(meta.addTag).toHaveBeenCalledWith({ property: 'citation_language', content: 'en' });
|
||||
expect(meta.addTag).toHaveBeenCalledWith({
|
||||
property: 'citation_keywords',
|
||||
content: 'keyword1; keyword2; keyword3'
|
||||
});
|
||||
}));
|
||||
|
||||
it('items page should set meta tags as published Thesis', fakeAsync(() => {
|
||||
spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(mockPublisher(mockType(ItemMock, 'Thesis'))));
|
||||
router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']);
|
||||
(metadataService as any).processRouteChange({
|
||||
data: {
|
||||
value: {
|
||||
dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Thesis'))),
|
||||
}
|
||||
}
|
||||
});
|
||||
tick();
|
||||
expect(tagStore.get('citation_dissertation_name')[0].content).toEqual('Test PowerPoint Document');
|
||||
expect(tagStore.get('citation_dissertation_institution')[0].content).toEqual('Mock Publisher');
|
||||
expect(tagStore.get('citation_abstract_html_url')[0].content).toEqual([environment.ui.baseUrl, router.url].join(''));
|
||||
expect(tagStore.get('citation_pdf_url')[0].content).toEqual('/bitstreams/99b00f3c-1cc6-4689-8158-91965bee6b28/download');
|
||||
expect(meta.addTag).toHaveBeenCalledWith({
|
||||
property: 'citation_dissertation_name',
|
||||
content: 'Test PowerPoint Document'
|
||||
});
|
||||
expect(meta.addTag).toHaveBeenCalledWith({
|
||||
property: 'citation_pdf_url',
|
||||
content: 'https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download'
|
||||
});
|
||||
}));
|
||||
|
||||
it('items page should set meta tags as published Technical Report', fakeAsync(() => {
|
||||
spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(mockPublisher(mockType(ItemMock, 'Technical Report'))));
|
||||
router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']);
|
||||
tick();
|
||||
expect(tagStore.get('citation_technical_report_institution')[0].content).toEqual('Mock Publisher');
|
||||
}));
|
||||
|
||||
it('other navigation should add title, description and Generator', fakeAsync(() => {
|
||||
spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(ItemMock));
|
||||
spyOn(translateService, 'get').and.returnValues(of('DSpace :: '), of('Dummy Title'), of('This is a dummy item component for testing!'));
|
||||
router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']);
|
||||
tick();
|
||||
expect(tagStore.size).toBeGreaterThan(0);
|
||||
router.navigate(['/other']);
|
||||
tick();
|
||||
expect(tagStore.size).toEqual(3);
|
||||
expect(title.getTitle()).toEqual('DSpace :: Dummy Title');
|
||||
expect(tagStore.get('title')[0].content).toEqual('DSpace :: Dummy Title');
|
||||
expect(tagStore.get('description')[0].content).toEqual('This is a dummy item component for testing!');
|
||||
expect(tagStore.get('Generator')[0].content).toEqual('mock-dspace-version');
|
||||
}));
|
||||
|
||||
describe('when the item has no bitstreams', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
// this.bitstreamDataService.findAllByItemAndBundleName(this.item, 'ORIGINAL')
|
||||
// spyOn(MockItem, 'getFiles').and.returnValue(observableOf([]));
|
||||
(metadataService as any).processRouteChange({
|
||||
data: {
|
||||
value: {
|
||||
dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Technical Report'))),
|
||||
}
|
||||
}
|
||||
});
|
||||
tick();
|
||||
expect(meta.addTag).toHaveBeenCalledWith({
|
||||
property: 'citation_technical_report_institution',
|
||||
content: 'Mock Publisher'
|
||||
});
|
||||
}));
|
||||
|
||||
it('processRemoteData should not produce an EmptyError', fakeAsync(() => {
|
||||
spyOn(itemDataService, 'findById').and.returnValue(mockRemoteData(ItemMock));
|
||||
spyOn(metadataService, 'processRemoteData').and.callThrough();
|
||||
router.navigate(['/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357']);
|
||||
it('other navigation should add title and description', fakeAsync(() => {
|
||||
(translateService.get as jasmine.Spy).and.returnValues(of('DSpace :: '), of('Dummy Title'), of('This is a dummy item component for testing!'));
|
||||
(metadataService as any).processRouteChange({
|
||||
data: {
|
||||
value: {
|
||||
title: 'Dummy Title',
|
||||
description: 'This is a dummy item component for testing!'
|
||||
}
|
||||
}
|
||||
});
|
||||
tick();
|
||||
expect(title.setTitle).toHaveBeenCalledWith('DSpace :: Dummy Title');
|
||||
expect(meta.addTag).toHaveBeenCalledWith({
|
||||
property: 'title',
|
||||
content: 'DSpace :: Dummy Title'
|
||||
});
|
||||
expect(meta.addTag).toHaveBeenCalledWith({
|
||||
property: 'description',
|
||||
content: 'This is a dummy item component for testing!'
|
||||
});
|
||||
}));
|
||||
|
||||
describe(`listenForRouteChange`, () => {
|
||||
it(`should call processRouteChange`, fakeAsync(() => {
|
||||
spyOn(metadataService as any, 'processRouteChange').and.callFake(() => undefined);
|
||||
metadataService.listenForRouteChange();
|
||||
tick();
|
||||
expect(metadataService.processRemoteData).not.toThrow(new EmptyError());
|
||||
expect((metadataService as any).processRouteChange).toHaveBeenCalled();
|
||||
}));
|
||||
it(`should add Generator`, fakeAsync(() => {
|
||||
spyOn(metadataService as any, 'processRouteChange').and.callFake(() => undefined);
|
||||
metadataService.listenForRouteChange();
|
||||
tick();
|
||||
expect(meta.addTag).toHaveBeenCalledWith({
|
||||
property: 'Generator',
|
||||
content: 'mock-dspace-version'
|
||||
});
|
||||
}));
|
||||
|
||||
});
|
||||
|
||||
const mockRemoteData = (mockItem: Item): Observable<RemoteData<Item>> => {
|
||||
return createSuccessfulRemoteDataObject$(ItemMock);
|
||||
};
|
||||
describe('citation_abstract_html_url', () => {
|
||||
it('should use dc.identifier.uri if available', fakeAsync(() => {
|
||||
(metadataService as any).processRouteChange({
|
||||
data: {
|
||||
value: {
|
||||
dso: createSuccessfulRemoteDataObject(mockUri(ItemMock, 'https://ddg.gg')),
|
||||
}
|
||||
}
|
||||
});
|
||||
tick();
|
||||
expect(meta.addTag).toHaveBeenCalledWith({
|
||||
property: 'citation_abstract_html_url',
|
||||
content: 'https://ddg.gg'
|
||||
});
|
||||
}));
|
||||
|
||||
it('should use current route as fallback', fakeAsync(() => {
|
||||
(metadataService as any).processRouteChange({
|
||||
data: {
|
||||
value: {
|
||||
dso: createSuccessfulRemoteDataObject(mockUri(ItemMock)),
|
||||
}
|
||||
}
|
||||
});
|
||||
tick();
|
||||
expect(meta.addTag).toHaveBeenCalledWith({
|
||||
property: 'citation_abstract_html_url',
|
||||
content: 'https://request.org/items/0ec7ff22-f211-40ab-a69e-c819b0b1f357'
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('citation_*_institution / citation_publisher', () => {
|
||||
it('should use citation_dissertation_institution tag for dissertations', fakeAsync(() => {
|
||||
(metadataService as any).processRouteChange({
|
||||
data: {
|
||||
value: {
|
||||
dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Thesis'))),
|
||||
}
|
||||
}
|
||||
});
|
||||
tick();
|
||||
expect(meta.addTag).toHaveBeenCalledWith({
|
||||
property: 'citation_dissertation_institution',
|
||||
content: 'Mock Publisher'
|
||||
});
|
||||
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ property: 'citation_technical_report_institution' }));
|
||||
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ property: 'citation_publisher' }));
|
||||
}));
|
||||
|
||||
it('should use citation_tech_report_institution tag for tech reports', fakeAsync(() => {
|
||||
(metadataService as any).processRouteChange({
|
||||
data: {
|
||||
value: {
|
||||
dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Technical Report'))),
|
||||
}
|
||||
}
|
||||
});
|
||||
tick();
|
||||
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ property: 'citation_dissertation_institution' }));
|
||||
expect(meta.addTag).toHaveBeenCalledWith({
|
||||
property: 'citation_technical_report_institution',
|
||||
content: 'Mock Publisher'
|
||||
});
|
||||
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ property: 'citation_publisher' }));
|
||||
}));
|
||||
|
||||
it('should use citation_publisher for other item types', fakeAsync(() => {
|
||||
(metadataService as any).processRouteChange({
|
||||
data: {
|
||||
value: {
|
||||
dso: createSuccessfulRemoteDataObject(mockPublisher(mockType(ItemMock, 'Some Other Type'))),
|
||||
}
|
||||
}
|
||||
});
|
||||
tick();
|
||||
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ property: 'citation_dissertation_institution' }));
|
||||
expect(meta.addTag).not.toHaveBeenCalledWith(jasmine.objectContaining({ property: 'citation_technical_report_institution' }));
|
||||
expect(meta.addTag).toHaveBeenCalledWith({
|
||||
property: 'citation_publisher',
|
||||
content: 'Mock Publisher'
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
describe('citation_pdf_url', () => {
|
||||
it('should link to primary Bitstream URL regardless of format', fakeAsync(() => {
|
||||
(bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$([], MockBitstream3));
|
||||
|
||||
(metadataService as any).processRouteChange({
|
||||
data: {
|
||||
value: {
|
||||
dso: createSuccessfulRemoteDataObject(ItemMock),
|
||||
}
|
||||
}
|
||||
});
|
||||
tick();
|
||||
expect(meta.addTag).toHaveBeenCalledWith({
|
||||
property: 'citation_pdf_url',
|
||||
content: 'https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download'
|
||||
});
|
||||
}));
|
||||
|
||||
describe('no primary Bitstream', () => {
|
||||
it('should link to first and only Bitstream regardless of format', fakeAsync(() => {
|
||||
(bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$([MockBitstream3]));
|
||||
|
||||
(metadataService as any).processRouteChange({
|
||||
data: {
|
||||
value: {
|
||||
dso: createSuccessfulRemoteDataObject(ItemMock),
|
||||
}
|
||||
}
|
||||
});
|
||||
tick();
|
||||
expect(meta.addTag).toHaveBeenCalledWith({
|
||||
property: 'citation_pdf_url',
|
||||
content: 'https://request.org/bitstreams/4db100c1-e1f5-4055-9404-9bc3e2d15f29/download'
|
||||
});
|
||||
}));
|
||||
|
||||
it('should link to first Bitstream with allowed format', fakeAsync(() => {
|
||||
const bitstreams = [MockBitstream3, MockBitstream3, MockBitstream1];
|
||||
(bundleDataService.findByItemAndName as jasmine.Spy).and.returnValue(mockBundleRD$(bitstreams));
|
||||
(bitstreamDataService.findAllByHref as jasmine.Spy).and.returnValues(
|
||||
...mockBitstreamPages$(bitstreams).map(bp => createSuccessfulRemoteDataObject$(bp)),
|
||||
);
|
||||
|
||||
(metadataService as any).processRouteChange({
|
||||
data: {
|
||||
value: {
|
||||
dso: createSuccessfulRemoteDataObject(ItemMock),
|
||||
}
|
||||
}
|
||||
});
|
||||
tick();
|
||||
expect(meta.addTag).toHaveBeenCalledWith({
|
||||
property: 'citation_pdf_url',
|
||||
content: 'https://request.org/bitstreams/cf9b0c8e-a1eb-4b65-afd0-567366448713/download'
|
||||
});
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('tagstore', () => {
|
||||
beforeEach(fakeAsync(() => {
|
||||
(metadataService as any).processRouteChange({
|
||||
data: {
|
||||
value: {
|
||||
dso: createSuccessfulRemoteDataObject(ItemMock),
|
||||
}
|
||||
}
|
||||
});
|
||||
tick();
|
||||
}));
|
||||
|
||||
it('should remove previous tags on route change', fakeAsync(() => {
|
||||
expect(meta.removeTag).toHaveBeenCalledWith('property=\'title\'');
|
||||
expect(meta.removeTag).toHaveBeenCalledWith('property=\'description\'');
|
||||
}));
|
||||
|
||||
it('should clear all tags and add new ones on route change', () => {
|
||||
expect(store.dispatch.calls.argsFor(0)).toEqual([new ClearMetaTagAction()]);
|
||||
expect(store.dispatch.calls.argsFor(1)).toEqual([new AddMetaTagAction('title')]);
|
||||
expect(store.dispatch.calls.argsFor(2)).toEqual([new AddMetaTagAction('description')]);
|
||||
});
|
||||
});
|
||||
|
||||
const mockType = (mockItem: Item, type: string): Item => {
|
||||
const typedMockItem = Object.assign(new Item(), mockItem) as Item;
|
||||
@@ -285,4 +382,30 @@ describe('MetadataService', () => {
|
||||
return publishedMockItem;
|
||||
};
|
||||
|
||||
const mockUri = (mockItem: Item, uri?: string): Item => {
|
||||
const publishedMockItem = Object.assign(new Item(), mockItem) as Item;
|
||||
publishedMockItem.metadata['dc.identifier.uri'] = [{ value: uri }] as MetadataValue[];
|
||||
return publishedMockItem;
|
||||
};
|
||||
|
||||
const mockBundleRD$ = (bitstreams: Bitstream[], primary?: Bitstream): Observable<RemoteData<Bundle>> => {
|
||||
return createSuccessfulRemoteDataObject$(
|
||||
Object.assign(new Bundle(), {
|
||||
name: 'ORIGINAL',
|
||||
bitstreams: createSuccessfulRemoteDataObject$(mockBitstreamPages$(bitstreams)[0]),
|
||||
primaryBitstream: createSuccessfulRemoteDataObject$(primary),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const mockBitstreamPages$ = (bitstreams: Bitstream[]): PaginatedList<Bitstream>[] => {
|
||||
return bitstreams.map((bitstream, index) => Object.assign(createPaginatedList([bitstream]), {
|
||||
pageInfo: {
|
||||
totalElements: bitstreams.length, // announce multiple elements/pages
|
||||
},
|
||||
_links: index < bitstreams.length - 1
|
||||
? { next: { href: 'not empty' }} // fake link to the next bitstream page
|
||||
: { next: { href: undefined }}, // last page has no link
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
@@ -5,12 +5,11 @@ import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
|
||||
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
|
||||
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
|
||||
import { catchError, distinctUntilKeyChanged, filter, first, map, take } from 'rxjs/operators';
|
||||
import { BehaviorSubject, combineLatest, EMPTY, Observable, of as observableOf } from 'rxjs';
|
||||
import { expand, filter, map, switchMap, take } from 'rxjs/operators';
|
||||
|
||||
import { hasValue, isNotEmpty } from '../../shared/empty.util';
|
||||
import { hasNoValue, hasValue } from '../../shared/empty.util';
|
||||
import { DSONameService } from '../breadcrumbs/dso-name.service';
|
||||
import { CacheableObject } from '../cache/object-cache.reducer';
|
||||
import { BitstreamDataService } from '../data/bitstream-data.service';
|
||||
import { BitstreamFormatDataService } from '../data/bitstream-format-data.service';
|
||||
|
||||
@@ -19,22 +18,57 @@ import { BitstreamFormat } from '../shared/bitstream-format.model';
|
||||
import { Bitstream } from '../shared/bitstream.model';
|
||||
import { DSpaceObject } from '../shared/dspace-object.model';
|
||||
import { Item } from '../shared/item.model';
|
||||
import {
|
||||
getFirstSucceededRemoteDataPayload,
|
||||
getFirstSucceededRemoteListPayload
|
||||
} from '../shared/operators';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../shared/operators';
|
||||
import { RootDataService } from '../data/root-data.service';
|
||||
import { getBitstreamDownloadRoute } from '../../app-routing-paths';
|
||||
import { BundleDataService } from '../data/bundle-data.service';
|
||||
import { followLink } from '../../shared/utils/follow-link-config.model';
|
||||
import { Bundle } from '../shared/bundle.model';
|
||||
import { PaginatedList } from '../data/paginated-list.model';
|
||||
import { URLCombiner } from '../url-combiner/url-combiner';
|
||||
import { HardRedirectService } from '../services/hard-redirect.service';
|
||||
import { MetaTagState } from './meta-tag.reducer';
|
||||
import { createSelector, select, Store } from '@ngrx/store';
|
||||
import { AddMetaTagAction, ClearMetaTagAction } from './meta-tag.actions';
|
||||
import { coreSelector } from '../core.selectors';
|
||||
import { CoreState } from '../core.reducers';
|
||||
|
||||
/**
|
||||
* The base selector function to select the metaTag section in the store
|
||||
*/
|
||||
const metaTagSelector = createSelector(
|
||||
coreSelector,
|
||||
(state: CoreState) => state.metaTag
|
||||
);
|
||||
|
||||
/**
|
||||
* Selector function to select the tags in use from the MetaTagState
|
||||
*/
|
||||
const tagsInUseSelector =
|
||||
createSelector(
|
||||
metaTagSelector,
|
||||
(state: MetaTagState) => state.tagsInUse,
|
||||
);
|
||||
|
||||
@Injectable()
|
||||
export class MetadataService {
|
||||
|
||||
private initialized: boolean;
|
||||
private currentObject: BehaviorSubject<DSpaceObject> = new BehaviorSubject<DSpaceObject>(undefined);
|
||||
|
||||
private tagStore: Map<string, MetaDefinition[]>;
|
||||
|
||||
private currentObject: BehaviorSubject<DSpaceObject>;
|
||||
/**
|
||||
* When generating the citation_pdf_url meta tag for Items with more than one Bitstream (and no primary Bitstream),
|
||||
* the first Bitstream to match one of the following MIME types is selected.
|
||||
* See {@linkcode getFirstAllowedFormatBitstreamLink}
|
||||
* @private
|
||||
*/
|
||||
private readonly CITATION_PDF_URL_MIMETYPES = [
|
||||
'application/pdf', // .pdf
|
||||
'application/postscript', // .ps
|
||||
'application/msword', // .doc
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
|
||||
'application/rtf', // .rtf
|
||||
'application/epub+zip', // .epub
|
||||
];
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
@@ -42,21 +76,19 @@ export class MetadataService {
|
||||
private meta: Meta,
|
||||
private title: Title,
|
||||
private dsoNameService: DSONameService,
|
||||
private bundleDataService: BundleDataService,
|
||||
private bitstreamDataService: BitstreamDataService,
|
||||
private bitstreamFormatDataService: BitstreamFormatDataService,
|
||||
private rootService: RootDataService
|
||||
private rootService: RootDataService,
|
||||
private store: Store<CoreState>,
|
||||
private hardRedirectService: HardRedirectService,
|
||||
) {
|
||||
// TODO: determine what open graph meta tags are needed and whether
|
||||
// the differ per route. potentially add image based on DSpaceObject
|
||||
this.meta.addTags([
|
||||
{ property: 'og:title', content: 'DSpace Angular Universal' },
|
||||
{ property: 'og:description', content: 'The modern front-end for DSpace 7.' }
|
||||
]);
|
||||
this.initialized = false;
|
||||
this.tagStore = new Map<string, MetaDefinition[]>();
|
||||
}
|
||||
|
||||
public listenForRouteChange(): void {
|
||||
// This never changes, set it only once
|
||||
this.setGenerator();
|
||||
|
||||
this.router.events.pipe(
|
||||
filter((event) => event instanceof NavigationEnd),
|
||||
map(() => this.router.routerState.root),
|
||||
@@ -68,22 +100,9 @@ export class MetadataService {
|
||||
});
|
||||
}
|
||||
|
||||
public processRemoteData(remoteData: Observable<RemoteData<CacheableObject>>): void {
|
||||
remoteData.pipe(map((rd: RemoteData<CacheableObject>) => rd.payload),
|
||||
filter((co: CacheableObject) => hasValue(co)),
|
||||
take(1))
|
||||
.subscribe((dspaceObject: DSpaceObject) => {
|
||||
if (!this.initialized) {
|
||||
this.initialize(dspaceObject);
|
||||
}
|
||||
this.currentObject.next(dspaceObject);
|
||||
});
|
||||
}
|
||||
|
||||
private processRouteChange(routeInfo: any): void {
|
||||
if (routeInfo.params.value.id === undefined) {
|
||||
this.clearMetaTags();
|
||||
}
|
||||
this.clearMetaTags();
|
||||
|
||||
if (routeInfo.data.value.title) {
|
||||
const titlePrefix = this.translate.get('repository.title.prefix');
|
||||
const title = this.translate.get(routeInfo.data.value.title, routeInfo.data.value);
|
||||
@@ -98,15 +117,10 @@ export class MetadataService {
|
||||
});
|
||||
}
|
||||
|
||||
this.setGenerator();
|
||||
}
|
||||
|
||||
private initialize(dspaceObject: DSpaceObject): void {
|
||||
this.currentObject = new BehaviorSubject<DSpaceObject>(dspaceObject);
|
||||
this.currentObject.asObservable().pipe(distinctUntilKeyChanged('uuid')).subscribe(() => {
|
||||
this.setMetaTags();
|
||||
});
|
||||
this.initialized = true;
|
||||
if (hasValue(routeInfo.data.value.dso) && hasValue(routeInfo.data.value.dso.payload)) {
|
||||
this.currentObject.next(routeInfo.data.value.dso.payload);
|
||||
this.setDSOMetaTags();
|
||||
}
|
||||
}
|
||||
|
||||
private getCurrentRoute(route: ActivatedRoute): ActivatedRoute {
|
||||
@@ -116,16 +130,14 @@ export class MetadataService {
|
||||
return route;
|
||||
}
|
||||
|
||||
private setMetaTags(): void {
|
||||
|
||||
this.clearMetaTags();
|
||||
private setDSOMetaTags(): void {
|
||||
|
||||
this.setTitleTag();
|
||||
this.setDescriptionTag();
|
||||
|
||||
this.setCitationTitleTag();
|
||||
this.setCitationAuthorTags();
|
||||
this.setCitationDateTag();
|
||||
this.setCitationPublicationDateTag();
|
||||
this.setCitationISSNTag();
|
||||
this.setCitationISBNTag();
|
||||
|
||||
@@ -134,14 +146,10 @@ export class MetadataService {
|
||||
|
||||
this.setCitationAbstractUrlTag();
|
||||
this.setCitationPdfUrlTag();
|
||||
this.setCitationPublisherTag();
|
||||
|
||||
if (this.isDissertation()) {
|
||||
this.setCitationDissertationNameTag();
|
||||
this.setCitationDissertationInstitutionTag();
|
||||
}
|
||||
|
||||
if (this.isTechReport()) {
|
||||
this.setCitationTechReportInstitutionTag();
|
||||
}
|
||||
|
||||
// this.setCitationJournalTitleTag();
|
||||
@@ -176,7 +184,7 @@ export class MetadataService {
|
||||
private setDescriptionTag(): void {
|
||||
// TODO: truncate abstract
|
||||
const value = this.getMetaTagValue('dc.description.abstract');
|
||||
this.addMetaTag('desciption', value);
|
||||
this.addMetaTag('description', value);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,11 +204,11 @@ export class MetadataService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add <meta name="citation_date" ... > to the <head>
|
||||
* Add <meta name="citation_publication_date" ... > to the <head>
|
||||
*/
|
||||
private setCitationDateTag(): void {
|
||||
private setCitationPublicationDateTag(): void {
|
||||
const value = this.getFirstMetaTagValue(['dc.date.copyright', 'dc.date.issued', 'dc.date.available', 'dc.date.accessioned']);
|
||||
this.addMetaTag('citation_date', value);
|
||||
this.addMetaTag('citation_publication_date', value);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,19 +244,17 @@ export class MetadataService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add <meta name="citation_dissertation_institution" ... > to the <head>
|
||||
* Add dc.publisher to the <head>. The tag name depends on the item type.
|
||||
*/
|
||||
private setCitationDissertationInstitutionTag(): void {
|
||||
private setCitationPublisherTag(): void {
|
||||
const value = this.getMetaTagValue('dc.publisher');
|
||||
this.addMetaTag('citation_dissertation_institution', value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add <meta name="citation_technical_report_institution" ... > to the <head>
|
||||
*/
|
||||
private setCitationTechReportInstitutionTag(): void {
|
||||
const value = this.getMetaTagValue('dc.publisher');
|
||||
this.addMetaTag('citation_technical_report_institution', value);
|
||||
if (this.isDissertation()) {
|
||||
this.addMetaTag('citation_dissertation_institution', value);
|
||||
} else if (this.isTechReport()) {
|
||||
this.addMetaTag('citation_technical_report_institution', value);
|
||||
} else {
|
||||
this.addMetaTag('citation_publisher', value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -264,8 +270,11 @@ export class MetadataService {
|
||||
*/
|
||||
private setCitationAbstractUrlTag(): void {
|
||||
if (this.currentObject.value instanceof Item) {
|
||||
const value = [environment.ui.baseUrl, this.router.url].join('');
|
||||
this.addMetaTag('citation_abstract_html_url', value);
|
||||
let url = this.getMetaTagValue('dc.identifier.uri');
|
||||
if (hasNoValue(url)) {
|
||||
url = new URLCombiner(this.hardRedirectService.getRequestOrigin(), this.router.url).toString();
|
||||
}
|
||||
this.addMetaTag('citation_abstract_html_url', url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,35 +284,126 @@ export class MetadataService {
|
||||
private setCitationPdfUrlTag(): void {
|
||||
if (this.currentObject.value instanceof Item) {
|
||||
const item = this.currentObject.value as Item;
|
||||
this.bitstreamDataService.findAllByItemAndBundleName(item, 'ORIGINAL')
|
||||
.pipe(
|
||||
getFirstSucceededRemoteListPayload(),
|
||||
first((files) => isNotEmpty(files)),
|
||||
catchError((error) => {
|
||||
console.debug(error.message);
|
||||
return [];
|
||||
}))
|
||||
.subscribe((bitstreams: Bitstream[]) => {
|
||||
for (const bitstream of bitstreams) {
|
||||
this.bitstreamFormatDataService.findByBitstream(bitstream).pipe(
|
||||
getFirstSucceededRemoteDataPayload()
|
||||
).subscribe((format: BitstreamFormat) => {
|
||||
if (format.mimetype === 'application/pdf') {
|
||||
const bitstreamLink = getBitstreamDownloadRoute(bitstream);
|
||||
this.addMetaTag('citation_pdf_url', bitstreamLink);
|
||||
|
||||
// Retrieve the ORIGINAL bundle for the item
|
||||
this.bundleDataService.findByItemAndName(
|
||||
item,
|
||||
'ORIGINAL',
|
||||
true,
|
||||
true,
|
||||
followLink('primaryBitstream'),
|
||||
followLink('bitstreams', {}, followLink('format')),
|
||||
).pipe(
|
||||
getFirstSucceededRemoteDataPayload(),
|
||||
switchMap((bundle: Bundle) =>
|
||||
|
||||
// First try the primary bitstream
|
||||
bundle.primaryBitstream.pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
map((rd: RemoteData<Bitstream>) => {
|
||||
if (hasValue(rd.payload)) {
|
||||
return rd.payload;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}),
|
||||
// return the bundle as well so we can use it again if there's no primary bitstream
|
||||
map((bitstream: Bitstream) => [bundle, bitstream])
|
||||
)
|
||||
),
|
||||
switchMap(([bundle, primaryBitstream]: [Bundle, Bitstream]) => {
|
||||
if (hasValue(primaryBitstream)) {
|
||||
// If there was a primary bitstream, emit its link
|
||||
return [getBitstreamDownloadRoute(primaryBitstream)];
|
||||
} else {
|
||||
// Otherwise consider the regular bitstreams in the bundle
|
||||
return bundle.bitstreams.pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
switchMap((bitstreamRd: RemoteData<PaginatedList<Bitstream>>) => {
|
||||
if (hasValue(bitstreamRd.payload) && bitstreamRd.payload.totalElements === 1) {
|
||||
// If there's only one bitstream in the bundle, emit its link
|
||||
return [getBitstreamDownloadRoute(bitstreamRd.payload.page[0])];
|
||||
} else {
|
||||
// Otherwise check all bitstreams to see if one matches the format whitelist
|
||||
return this.getFirstAllowedFormatBitstreamLink(bitstreamRd);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
}),
|
||||
take(1)
|
||||
).subscribe((link: string) => {
|
||||
// Use the found link to set the <meta> tag
|
||||
this.addMetaTag(
|
||||
'citation_pdf_url',
|
||||
new URLCombiner(this.hardRedirectService.getRequestOrigin(), link).toString()
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For Items with more than one Bitstream (and no primary Bitstream), link to the first Bitstream with a MIME type
|
||||
* included in {@linkcode CITATION_PDF_URL_MIMETYPES}
|
||||
* @param bitstreamRd
|
||||
* @private
|
||||
*/
|
||||
private getFirstAllowedFormatBitstreamLink(bitstreamRd: RemoteData<PaginatedList<Bitstream>>): Observable<string> {
|
||||
return observableOf(bitstreamRd.payload).pipe(
|
||||
// Because there can be more than one page of bitstreams, this expand operator
|
||||
// will retrieve them in turn. Due to the take(1) at the bottom, it will only
|
||||
// retrieve pages until a match is found
|
||||
expand((paginatedList: PaginatedList<Bitstream>) => {
|
||||
if (hasNoValue(paginatedList.next)) {
|
||||
// If there's no next page, stop.
|
||||
return EMPTY;
|
||||
} else {
|
||||
// Otherwise retrieve the next page
|
||||
return this.bitstreamDataService.findAllByHref(
|
||||
paginatedList.next,
|
||||
undefined,
|
||||
true,
|
||||
true,
|
||||
followLink('format')
|
||||
).pipe(
|
||||
getFirstCompletedRemoteData(),
|
||||
map((next: RemoteData<PaginatedList<Bitstream>>) => {
|
||||
if (hasValue(next.payload)) {
|
||||
return next.payload;
|
||||
} else {
|
||||
return EMPTY;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}),
|
||||
// Return the array of bitstreams inside each paginated list
|
||||
map((paginatedList: PaginatedList<Bitstream>) => paginatedList.page),
|
||||
// Emit the bitstreams in the list one at a time
|
||||
switchMap((bitstreams: Bitstream[]) => bitstreams),
|
||||
// Retrieve the format for each bitstream
|
||||
switchMap((bitstream: Bitstream) => bitstream.format.pipe(
|
||||
getFirstSucceededRemoteDataPayload(),
|
||||
// Keep the original bitstream, because it, not the format, is what we'll need
|
||||
// for the link at the end
|
||||
map((format: BitstreamFormat) => [bitstream, format])
|
||||
)),
|
||||
// Filter out only pairs with whitelisted formats
|
||||
filter(([, format]: [Bitstream, BitstreamFormat]) =>
|
||||
hasValue(format) && this.CITATION_PDF_URL_MIMETYPES.includes(format.mimetype)),
|
||||
// We only need 1
|
||||
take(1),
|
||||
// Emit the link of the match
|
||||
map(([bitstream, ]: [Bitstream, BitstreamFormat]) => getBitstreamDownloadRoute(bitstream))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add <meta name="Generator" ... > to the <head> containing the current DSpace version
|
||||
*/
|
||||
private setGenerator(): void {
|
||||
this.rootService.findRoot().pipe(getFirstSucceededRemoteDataPayload()).subscribe((root) => {
|
||||
this.addMetaTag('Generator', root.dspaceVersion);
|
||||
this.meta.addTag({ property: 'Generator', content: root.dspaceVersion });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -351,7 +451,7 @@ export class MetadataService {
|
||||
if (content) {
|
||||
const tag = { property, content } as MetaDefinition;
|
||||
this.meta.addTag(tag);
|
||||
this.storeTag(property, tag);
|
||||
this.storeTag(property);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,33 +461,21 @@ export class MetadataService {
|
||||
}
|
||||
}
|
||||
|
||||
private storeTag(key: string, tag: MetaDefinition): void {
|
||||
const tags: MetaDefinition[] = this.getTags(key);
|
||||
tags.push(tag);
|
||||
this.setTags(key, tags);
|
||||
}
|
||||
|
||||
private getTags(key: string): MetaDefinition[] {
|
||||
let tags: MetaDefinition[] = this.tagStore.get(key);
|
||||
if (tags === undefined) {
|
||||
tags = [];
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
private setTags(key: string, tags: MetaDefinition[]): void {
|
||||
this.tagStore.set(key, tags);
|
||||
private storeTag(key: string): void {
|
||||
this.store.dispatch(new AddMetaTagAction(key));
|
||||
}
|
||||
|
||||
public clearMetaTags() {
|
||||
this.tagStore.forEach((tags: MetaDefinition[], property: string) => {
|
||||
this.meta.removeTag('property=\'' + property + '\'');
|
||||
this.store.pipe(
|
||||
select(tagsInUseSelector),
|
||||
take(1)
|
||||
).subscribe((tagsInUse: string[]) => {
|
||||
for (const property of tagsInUse) {
|
||||
this.meta.removeTag('property=\'' + property + '\'');
|
||||
}
|
||||
this.store.dispatch(new ClearMetaTagAction());
|
||||
});
|
||||
this.tagStore.clear();
|
||||
}
|
||||
|
||||
public getTagStore(): Map<string, MetaDefinition[]> {
|
||||
return this.tagStore;
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -43,13 +43,14 @@ export class Bitstream extends DSpaceObject implements HALResource {
|
||||
bundle: HALLink;
|
||||
format: HALLink;
|
||||
content: HALLink;
|
||||
thumbnail: HALLink;
|
||||
};
|
||||
|
||||
/**
|
||||
* The thumbnail for this Bitstream
|
||||
* Needs to be resolved first, but isn't available as a {@link HALLink} yet
|
||||
* Use BitstreamDataService.getThumbnailFor(…) for now.
|
||||
* Will be undefined unless the thumbnail {@link HALLink} has been resolved.
|
||||
*/
|
||||
@link(BITSTREAM, false, 'thumbnail')
|
||||
thumbnail?: Observable<RemoteData<Bitstream>>;
|
||||
|
||||
/**
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { autoserialize, autoserializeAs, deserialize, inheritSerialization } from 'cerialize';
|
||||
import { autoserialize, autoserializeAs, deserialize, deserializeAs, inheritSerialization } from 'cerialize';
|
||||
import { Observable } from 'rxjs';
|
||||
import { isEmpty } from '../../shared/empty.util';
|
||||
import { ListableObject } from '../../shared/object-collection/shared/listable-object.model';
|
||||
@@ -19,6 +19,8 @@ import { ITEM } from './item.resource-type';
|
||||
import { ChildHALResource } from './child-hal-resource.model';
|
||||
import { Version } from './version.model';
|
||||
import { VERSION } from './version.resource-type';
|
||||
import { BITSTREAM } from './bitstream.resource-type';
|
||||
import { Bitstream } from './bitstream.model';
|
||||
|
||||
/**
|
||||
* Class representing a DSpace Item
|
||||
@@ -37,7 +39,7 @@ export class Item extends DSpaceObject implements ChildHALResource {
|
||||
/**
|
||||
* The Date of the last modification of this Item
|
||||
*/
|
||||
@deserialize
|
||||
@deserializeAs(Date)
|
||||
lastModified: Date;
|
||||
|
||||
/**
|
||||
@@ -69,6 +71,7 @@ export class Item extends DSpaceObject implements ChildHALResource {
|
||||
owningCollection: HALLink;
|
||||
templateItemOf: HALLink;
|
||||
version: HALLink;
|
||||
thumbnail: HALLink;
|
||||
self: HALLink;
|
||||
};
|
||||
|
||||
@@ -100,6 +103,13 @@ export class Item extends DSpaceObject implements ChildHALResource {
|
||||
@link(RELATIONSHIP, true)
|
||||
relationships?: Observable<RemoteData<PaginatedList<Relationship>>>;
|
||||
|
||||
/**
|
||||
* The thumbnail for this Item
|
||||
* Will be undefined unless the thumbnail {@link HALLink} has been resolved.
|
||||
*/
|
||||
@link(BITSTREAM, false, 'thumbnail')
|
||||
thumbnail?: Observable<RemoteData<Bitstream>>;
|
||||
|
||||
/**
|
||||
* Method that returns as which type of object this object should be rendered
|
||||
*/
|
||||
|
@@ -41,6 +41,43 @@ import { SearchConfig } from './search-filters/search-config.model';
|
||||
import { PaginationService } from '../../pagination/pagination.service';
|
||||
import { SearchConfigurationService } from './search-configuration.service';
|
||||
import { PaginationComponentOptions } from '../../../shared/pagination/pagination-component-options.model';
|
||||
import { DataService } from '../../data/data.service';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { CoreState } from '../../core.reducers';
|
||||
import { ObjectCacheService } from '../../cache/object-cache.service';
|
||||
import { NotificationsService } from '../../../shared/notifications/notifications.service';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { DSOChangeAnalyzer } from '../../data/dso-change-analyzer.service';
|
||||
|
||||
/* tslint:disable:max-classes-per-file */
|
||||
/**
|
||||
* A class that lets us delegate some methods to DataService
|
||||
*/
|
||||
class DataServiceImpl extends DataService<any> {
|
||||
protected linkPath = 'discover';
|
||||
|
||||
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: DSOChangeAnalyzer<any>) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the embed options to the link for the request
|
||||
* @param href The href the params are to be added to
|
||||
* @param args params for the query string
|
||||
* @param linksToFollow links we want to embed in query string if shouldEmbed is true
|
||||
*/
|
||||
public addEmbedParams(href: string, args: string[], ...linksToFollow: FollowLinkConfig<any>[]) {
|
||||
return super.addEmbedParams(href, args, ...linksToFollow);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Service that performs all general actions that have to do with the search page
|
||||
@@ -78,6 +115,11 @@ export class SearchService implements OnDestroy {
|
||||
*/
|
||||
private sub;
|
||||
|
||||
/**
|
||||
* Instance of DataServiceImpl that lets us delegate some methods to DataService
|
||||
*/
|
||||
private searchDataService: DataServiceImpl;
|
||||
|
||||
constructor(private router: Router,
|
||||
private routeService: RouteService,
|
||||
protected requestService: RequestService,
|
||||
@@ -89,6 +131,16 @@ export class SearchService implements OnDestroy {
|
||||
private paginationService: PaginationService,
|
||||
private searchConfigurationService: SearchConfigurationService
|
||||
) {
|
||||
this.searchDataService = new DataServiceImpl(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,7 +183,17 @@ export class SearchService implements OnDestroy {
|
||||
search<T extends DSpaceObject>(searchOptions?: PaginatedSearchOptions, responseMsToLive?: number, useCachedVersionIfAvailable = true, reRequestOnStale = true, ...linksToFollow: FollowLinkConfig<T>[]): Observable<RemoteData<SearchObjects<T>>> {
|
||||
const href$ = this.getEndpoint(searchOptions);
|
||||
|
||||
href$.pipe(take(1)).subscribe((url: string) => {
|
||||
href$.pipe(
|
||||
take(1),
|
||||
map((href: string) => {
|
||||
const args = this.searchDataService.addEmbedParams(href, [], ...linksToFollow);
|
||||
if (isNotEmpty(args)) {
|
||||
return new URLCombiner(href, `?${args.join('&')}`).toString();
|
||||
} else {
|
||||
return href;
|
||||
}
|
||||
})
|
||||
).subscribe((url: string) => {
|
||||
const request = new this.request(this.requestService.generateRequestId(), url);
|
||||
|
||||
const getResponseParserFn: () => GenericConstructor<ResponseParsingService> = () => {
|
||||
@@ -152,7 +214,7 @@ export class SearchService implements OnDestroy {
|
||||
);
|
||||
|
||||
return this.directlyAttachIndexableObjects(sqr$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to retrieve request entries for search results from the server
|
||||
@@ -399,9 +461,9 @@ export class SearchService implements OnDestroy {
|
||||
let pageParams = { page: 1 };
|
||||
const queryParams = { view: viewMode };
|
||||
if (viewMode === ViewMode.DetailedListElement) {
|
||||
pageParams = Object.assign(pageParams, {pageSize: 1});
|
||||
pageParams = Object.assign(pageParams, { pageSize: 1 });
|
||||
} else if (config.pageSize === 1) {
|
||||
pageParams = Object.assign(pageParams, {pageSize: 10});
|
||||
pageParams = Object.assign(pageParams, { pageSize: 10 });
|
||||
}
|
||||
this.paginationService.updateRouteWithUrl(this.searchConfigurationService.paginationID, hasValue(searchLinkParts) ? searchLinkParts : [this.getSearchLink()], pageParams, queryParams);
|
||||
});
|
||||
@@ -413,7 +475,7 @@ export class SearchService implements OnDestroy {
|
||||
* @param {string} configurationName the name of the configuration
|
||||
* @returns {Observable<RemoteData<SearchConfig[]>>} The found configuration
|
||||
*/
|
||||
getSearchConfigurationFor(scope?: string, configurationName?: string ): Observable<RemoteData<SearchConfig>> {
|
||||
getSearchConfigurationFor(scope?: string, configurationName?: string): Observable<RemoteData<SearchConfig>> {
|
||||
const href$ = this.halService.getEndpoint(this.configurationLinkPath).pipe(
|
||||
map((url: string) => this.getConfigUrl(url, scope, configurationName)),
|
||||
);
|
||||
|
22
src/app/core/shared/sequence.service.spec.ts
Normal file
22
src/app/core/shared/sequence.service.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* The contents of this file are subject to the license and copyright
|
||||
* detailed in the LICENSE and NOTICE files at the root of the source
|
||||
* tree and available online at
|
||||
*
|
||||
* http://www.dspace.org/license/
|
||||
*/
|
||||
import { SequenceService } from './sequence.service';
|
||||
|
||||
let service: SequenceService;
|
||||
|
||||
describe('SequenceService', () => {
|
||||
beforeEach(() => {
|
||||
service = new SequenceService();
|
||||
});
|
||||
|
||||
it('should return sequential numbers on next(), starting with 1', () => {
|
||||
const NUMBERS = [1,2,3,4,5];
|
||||
const sequence = NUMBERS.map(() => service.next());
|
||||
expect(sequence).toEqual(NUMBERS);
|
||||
});
|
||||
});
|
24
src/app/core/shared/sequence.service.ts
Normal file
24
src/app/core/shared/sequence.service.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* The contents of this file are subject to the license and copyright
|
||||
* detailed in the LICENSE and NOTICE files at the root of the source
|
||||
* tree and available online at
|
||||
*
|
||||
* http://www.dspace.org/license/
|
||||
*/
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable()
|
||||
/**
|
||||
* Provides unique sequential numbers
|
||||
*/
|
||||
export class SequenceService {
|
||||
private value: number;
|
||||
|
||||
constructor() {
|
||||
this.value = 0;
|
||||
}
|
||||
|
||||
public next(): number {
|
||||
return ++this.value;
|
||||
}
|
||||
}
|
@@ -173,7 +173,7 @@ export class VocabularyService {
|
||||
);
|
||||
|
||||
// TODO remove false for the entries embed when https://github.com/DSpace/DSpace/issues/3096 is solved
|
||||
return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', options, false)).pipe(
|
||||
return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', { findListOptions: options, shouldEmbed: false })).pipe(
|
||||
getFirstSucceededRemoteDataPayload(),
|
||||
switchMap((vocabulary: Vocabulary) => vocabulary.entries),
|
||||
);
|
||||
@@ -200,7 +200,7 @@ export class VocabularyService {
|
||||
);
|
||||
|
||||
// TODO remove false for the entries embed when https://github.com/DSpace/DSpace/issues/3096 is solved
|
||||
return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', options, false)).pipe(
|
||||
return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', { findListOptions: options, shouldEmbed: false })).pipe(
|
||||
getFirstSucceededRemoteDataPayload(),
|
||||
switchMap((vocabulary: Vocabulary) => vocabulary.entries),
|
||||
);
|
||||
@@ -249,7 +249,7 @@ export class VocabularyService {
|
||||
);
|
||||
|
||||
// TODO remove false for the entries embed when https://github.com/DSpace/DSpace/issues/3096 is solved
|
||||
return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', options, false)).pipe(
|
||||
return this.findVocabularyById(vocabularyOptions.name, true, true, followLink('entries', { findListOptions: options, shouldEmbed: false })).pipe(
|
||||
getFirstSucceededRemoteDataPayload(),
|
||||
switchMap((vocabulary: Vocabulary) => vocabulary.entries),
|
||||
getFirstSucceededRemoteListPayload(),
|
||||
|
@@ -8,14 +8,14 @@
|
||||
rel="noopener noreferrer" [routerLink]="[itemPageRoute]"
|
||||
class="card-img-top full-width">
|
||||
<div>
|
||||
<ds-grid-thumbnail [thumbnail]="getThumbnail() | async">
|
||||
</ds-grid-thumbnail>
|
||||
<ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
|
||||
</ds-thumbnail>
|
||||
</div>
|
||||
</a>
|
||||
<span *ngIf="linkType == linkTypes.None" class="card-img-top full-width">
|
||||
<div>
|
||||
<ds-grid-thumbnail [thumbnail]="getThumbnail() | async">
|
||||
</ds-grid-thumbnail>
|
||||
<ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
|
||||
</ds-thumbnail>
|
||||
</div>
|
||||
</span>
|
||||
<div class="card-body">
|
||||
|
@@ -8,14 +8,14 @@
|
||||
rel="noopener noreferrer" [routerLink]="[itemPageRoute]"
|
||||
class="card-img-top full-width">
|
||||
<div>
|
||||
<ds-grid-thumbnail [thumbnail]="getThumbnail() | async">
|
||||
</ds-grid-thumbnail>
|
||||
<ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
|
||||
</ds-thumbnail>
|
||||
</div>
|
||||
</a>
|
||||
<span *ngIf="linkType == linkTypes.None" class="card-img-top full-width">
|
||||
<div>
|
||||
<ds-grid-thumbnail [thumbnail]="getThumbnail() | async">
|
||||
</ds-grid-thumbnail>
|
||||
<ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
|
||||
</ds-thumbnail>
|
||||
</div>
|
||||
</span>
|
||||
<div class="card-body">
|
||||
|
@@ -8,14 +8,14 @@
|
||||
rel="noopener noreferrer" [routerLink]="[itemPageRoute]"
|
||||
class="card-img-top full-width">
|
||||
<div>
|
||||
<ds-grid-thumbnail [thumbnail]="getThumbnail() | async">
|
||||
</ds-grid-thumbnail>
|
||||
<ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
|
||||
</ds-thumbnail>
|
||||
</div>
|
||||
</a>
|
||||
<span *ngIf="linkType == linkTypes.None" class="card-img-top full-width">
|
||||
<div>
|
||||
<ds-grid-thumbnail [thumbnail]="getThumbnail() | async">
|
||||
</ds-grid-thumbnail>
|
||||
<ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
|
||||
</ds-thumbnail>
|
||||
</div>
|
||||
</span>
|
||||
<div class="card-body">
|
||||
|
@@ -8,8 +8,8 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-4">
|
||||
<ds-metadata-field-wrapper>
|
||||
<ds-thumbnail [thumbnail]="getThumbnail() | async"></ds-thumbnail>
|
||||
<ds-metadata-field-wrapper [hideIfNoTextContent]="false">
|
||||
<ds-thumbnail [thumbnail]="object?.thumbnail | async"></ds-thumbnail>
|
||||
</ds-metadata-field-wrapper>
|
||||
<ds-generic-item-page-field [item]="object"
|
||||
[fields]="['publicationvolume.volumeNumber']"
|
||||
|
@@ -8,8 +8,8 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-4">
|
||||
<ds-metadata-field-wrapper>
|
||||
<ds-thumbnail [thumbnail]="getThumbnail() | async"></ds-thumbnail>
|
||||
<ds-metadata-field-wrapper [hideIfNoTextContent]="false">
|
||||
<ds-thumbnail [thumbnail]="object?.thumbnail | async"></ds-thumbnail>
|
||||
</ds-metadata-field-wrapper>
|
||||
<ds-generic-item-page-field [item]="object"
|
||||
[fields]="['publicationvolume.volumeNumber']"
|
||||
|
@@ -8,8 +8,8 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-4">
|
||||
<ds-metadata-field-wrapper>
|
||||
<ds-thumbnail [thumbnail]="getThumbnail() | async"></ds-thumbnail>
|
||||
<ds-metadata-field-wrapper [hideIfNoTextContent]="false">
|
||||
<ds-thumbnail [thumbnail]="object?.thumbnail | async"></ds-thumbnail>
|
||||
</ds-metadata-field-wrapper>
|
||||
<ds-generic-item-page-field class="item-page-fields" [item]="object"
|
||||
[fields]="['creativeworkseries.issn']"
|
||||
|
@@ -8,14 +8,14 @@
|
||||
rel="noopener noreferrer" [routerLink]="[itemPageRoute]"
|
||||
class="card-img-top full-width">
|
||||
<div>
|
||||
<ds-grid-thumbnail [thumbnail]="getThumbnail() | async">
|
||||
</ds-grid-thumbnail>
|
||||
<ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
|
||||
</ds-thumbnail>
|
||||
</div>
|
||||
</a>
|
||||
<span *ngIf="linkType == linkTypes.None" class="card-img-top full-width">
|
||||
<div>
|
||||
<ds-grid-thumbnail [thumbnail]="getThumbnail() | async">
|
||||
</ds-grid-thumbnail>
|
||||
<ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
|
||||
</ds-thumbnail>
|
||||
</div>
|
||||
</span>
|
||||
<div class="card-body">
|
||||
|
@@ -8,14 +8,14 @@
|
||||
rel="noopener noreferrer" [routerLink]="[itemPageRoute]"
|
||||
class="card-img-top full-width">
|
||||
<div>
|
||||
<ds-grid-thumbnail [thumbnail]="getThumbnail() | async">
|
||||
</ds-grid-thumbnail>
|
||||
<ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
|
||||
</ds-thumbnail>
|
||||
</div>
|
||||
</a>
|
||||
<span *ngIf="linkType == linkTypes.None" class="card-img-top full-width">
|
||||
<div>
|
||||
<ds-grid-thumbnail [thumbnail]="getThumbnail() | async">
|
||||
</ds-grid-thumbnail>
|
||||
<ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
|
||||
</ds-thumbnail>
|
||||
</div>
|
||||
</span>
|
||||
<div class="card-body">
|
||||
|
@@ -8,14 +8,14 @@
|
||||
rel="noopener noreferrer" [routerLink]="[itemPageRoute]"
|
||||
class="card-img-top full-width">
|
||||
<div>
|
||||
<ds-grid-thumbnail [thumbnail]="getThumbnail() | async">
|
||||
</ds-grid-thumbnail>
|
||||
<ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
|
||||
</ds-thumbnail>
|
||||
</div>
|
||||
</a>
|
||||
<span *ngIf="linkType == linkTypes.None" class="card-img-top full-width">
|
||||
<div>
|
||||
<ds-grid-thumbnail [thumbnail]="getThumbnail() | async">
|
||||
</ds-grid-thumbnail>
|
||||
<ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="false">
|
||||
</ds-thumbnail>
|
||||
</div>
|
||||
</span>
|
||||
<div class="card-body">
|
||||
|
@@ -8,8 +8,13 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-4">
|
||||
<ds-metadata-field-wrapper>
|
||||
<ds-thumbnail [thumbnail]="getThumbnail() | async" [defaultImage]="'assets/images/orgunit-placeholder.svg'"></ds-thumbnail>
|
||||
<ds-metadata-field-wrapper [hideIfNoTextContent]="false">
|
||||
<ds-thumbnail [thumbnail]="object?.thumbnail | async"
|
||||
[defaultImage]="'assets/images/orgunit-placeholder.svg'"
|
||||
[alt]="'thumbnail.orgunit.alt'"
|
||||
[placeholder]="'thumbnail.orgunit.placeholder'"
|
||||
>
|
||||
</ds-thumbnail>
|
||||
</ds-metadata-field-wrapper>
|
||||
<ds-generic-item-page-field [item]="object"
|
||||
[fields]="['organization.foundingDate']"
|
||||
|
@@ -8,8 +8,12 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-4">
|
||||
<ds-metadata-field-wrapper>
|
||||
<ds-thumbnail [thumbnail]="getThumbnail() | async" [defaultImage]="'assets/images/person-placeholder.svg'"></ds-thumbnail>
|
||||
<ds-metadata-field-wrapper [hideIfNoTextContent]="false">
|
||||
<ds-thumbnail [thumbnail]="object?.thumbnail | async"
|
||||
[defaultImage]="'assets/images/person-placeholder.svg'"
|
||||
[alt]="'thumbnail.person.alt'"
|
||||
[placeholder]="'thumbnail.person.placeholder'">
|
||||
</ds-thumbnail>
|
||||
</ds-metadata-field-wrapper>
|
||||
<ds-generic-item-page-field [item]="object"
|
||||
[fields]="['person.email']"
|
||||
|
@@ -8,8 +8,13 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-4">
|
||||
<ds-metadata-field-wrapper>
|
||||
<ds-thumbnail [thumbnail]="getThumbnail() | async" [defaultImage]="'assets/images/project-placeholder.svg'"></ds-thumbnail>
|
||||
<ds-metadata-field-wrapper [hideIfNoTextContent]="false">
|
||||
<ds-thumbnail
|
||||
[thumbnail]="object?.thumbnail | async"
|
||||
[defaultImage]="'assets/images/project-placeholder.svg'"
|
||||
[alt]="'thumbnail.project.alt'"
|
||||
[placeholder]="'thumbnail.project.placeholder'">
|
||||
</ds-thumbnail>
|
||||
</ds-metadata-field-wrapper>
|
||||
<!--<ds-generic-item-page-field [item]="object"-->
|
||||
<!--[fields]="['project.identifier.status']"-->
|
||||
|
@@ -1,6 +1,6 @@
|
||||
<div class="d-flex">
|
||||
<!-- <div class="person-thumbnail pr-2">-->
|
||||
<!-- <ds-thumbnail [thumbnail]="getThumbnail() | async" [defaultImage]="'assets/images/orgunit-placeholder.svg'"></ds-thumbnail>-->
|
||||
<!-- <ds-thumbnail [thumbnail]="dso?.thumbnail | async" [defaultImage]="'assets/images/orgunit-placeholder.svg'"></ds-thumbnail>-->
|
||||
<!-- </div>-->
|
||||
<div class="flex-grow-1">
|
||||
<ds-org-unit-input-suggestions *ngIf="useNameVariants" [suggestions]="allSuggestions" [(ngModel)]="selectedName" (clickSuggestion)="select($event)"
|
||||
|
@@ -1,8 +1,5 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service';
|
||||
import { Bitstream } from '../../../../../core/shared/bitstream.model';
|
||||
import { getFirstSucceededRemoteDataPayload } from '../../../../../core/shared/operators';
|
||||
import { SearchResultListElementComponent } from '../../../../../shared/object-list/search-result-list-element/search-result-list-element.component';
|
||||
import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
|
||||
import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
|
||||
@@ -113,11 +110,4 @@ export class OrgUnitSearchResultListSubmissionElementComponent extends SearchRes
|
||||
modalComp.value = value;
|
||||
return modalRef.result;
|
||||
}
|
||||
|
||||
// TODO refactor to return RemoteData, and thumbnail template to deal with loading
|
||||
getThumbnail(): Observable<Bitstream> {
|
||||
return this.bitstreamDataService.getThumbnailFor(this.dso).pipe(
|
||||
getFirstSucceededRemoteDataPayload()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -21,4 +21,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
|
@@ -1,8 +1,5 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { BitstreamDataService } from '../../../../../core/data/bitstream-data.service';
|
||||
import { Bitstream } from '../../../../../core/shared/bitstream.model';
|
||||
import { getFirstSucceededRemoteDataPayload } from '../../../../../core/shared/operators';
|
||||
import { SearchResultListElementComponent } from '../../../../../shared/object-list/search-result-list-element/search-result-list-element.component';
|
||||
import { ItemSearchResult } from '../../../../../shared/object-collection/shared/item-search-result.model';
|
||||
import { listableObjectComponent } from '../../../../../shared/object-collection/shared/listable-object/listable-object.decorator';
|
||||
@@ -108,11 +105,4 @@ export class PersonSearchResultListSubmissionElementComponent extends SearchResu
|
||||
modalComp.value = value;
|
||||
return modalRef.result;
|
||||
}
|
||||
|
||||
// TODO refactor to return RemoteData, and thumbnail template to deal with loading
|
||||
getThumbnail(): Observable<Bitstream> {
|
||||
return this.bitstreamDataService.getThumbnailFor(this.dso).pipe(
|
||||
getFirstSucceededRemoteDataPayload()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { RemoteData } from '../../../core/data/remote-data';
|
||||
import { first, map } from 'rxjs/operators';
|
||||
@@ -29,6 +29,12 @@ export class DeleteComColPageComponent<TDomain extends Community | Collection> i
|
||||
*/
|
||||
public dsoRD$: Observable<RemoteData<TDomain>>;
|
||||
|
||||
/**
|
||||
* A boolean representing if a delete operation is pending
|
||||
* @type {BehaviorSubject<boolean>}
|
||||
*/
|
||||
public processing$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
|
||||
|
||||
public constructor(
|
||||
protected dsoDataService: ComColDataService<TDomain>,
|
||||
protected router: Router,
|
||||
@@ -48,6 +54,7 @@ export class DeleteComColPageComponent<TDomain extends Community | Collection> i
|
||||
* Deletes an existing DSO and redirects to the home page afterwards, showing a notification that states whether or not the deletion was successful
|
||||
*/
|
||||
onConfirm(dso: TDomain) {
|
||||
this.processing$.next(true);
|
||||
this.dsoDataService.delete(dso.id)
|
||||
.pipe(getFirstCompletedRemoteData())
|
||||
.subscribe((response: RemoteData<NoContent>) => {
|
||||
|
@@ -3,7 +3,7 @@ import { NgbDateStruct } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { isObject } from 'lodash';
|
||||
import * as moment from 'moment';
|
||||
|
||||
import { isNull } from './empty.util';
|
||||
import { isNull, isUndefined } from './empty.util';
|
||||
|
||||
/**
|
||||
* Returns true if the passed value is a NgbDateStruct.
|
||||
@@ -27,8 +27,9 @@ export function isNgbDateStruct(value: object): boolean {
|
||||
* @return string
|
||||
* the formatted date
|
||||
*/
|
||||
export function dateToISOFormat(date: Date | NgbDateStruct): string {
|
||||
const dateObj: Date = (date instanceof Date) ? date : ngbDateStructToDate(date);
|
||||
export function dateToISOFormat(date: Date | NgbDateStruct | string): string {
|
||||
const dateObj: Date = (date instanceof Date) ? date :
|
||||
((typeof date === 'string') ? ngbDateStructToDate(stringToNgbDateStruct(date)) : ngbDateStructToDate(date));
|
||||
|
||||
let year = dateObj.getFullYear().toString();
|
||||
let month = (dateObj.getMonth() + 1).toString();
|
||||
@@ -80,7 +81,7 @@ export function stringToNgbDateStruct(date: string): NgbDateStruct {
|
||||
* the NgbDateStruct object
|
||||
*/
|
||||
export function dateToNgbDateStruct(date?: Date): NgbDateStruct {
|
||||
if (isNull(date)) {
|
||||
if (isNull(date) || isUndefined(date)) {
|
||||
date = new Date();
|
||||
}
|
||||
|
||||
|
@@ -14,12 +14,12 @@
|
||||
[infiniteScrollContainer]="'.scrollable-menu'"
|
||||
[fromRoot]="true"
|
||||
(scrolled)="onScrollDown()">
|
||||
<ng-container *ngIf="listEntries">
|
||||
<ng-container *ngIf="listEntries$ | async">
|
||||
<button class="list-group-item list-group-item-action border-0 disabled"
|
||||
*ngIf="listEntries.length == 0">
|
||||
*ngIf="(listEntries$ | async).length == 0">
|
||||
{{'dso-selector.no-results' | translate: { type: typesString } }}
|
||||
</button>
|
||||
<button *ngFor="let listEntry of listEntries"
|
||||
<button *ngFor="let listEntry of (listEntries$ | async)"
|
||||
class="list-group-item list-group-item-action border-0 list-entry"
|
||||
[ngClass]="{'bg-primary': listEntry.indexableObject.id === currentDSOId}"
|
||||
title="{{ listEntry.indexableObject.name }}"
|
||||
|
@@ -92,12 +92,18 @@ describe('DSOSelectorComponent', () => {
|
||||
});
|
||||
|
||||
describe('populating listEntries', () => {
|
||||
it('should not be empty', () => {
|
||||
expect(component.listEntries.length).toBeGreaterThan(0);
|
||||
it('should not be empty', (done) => {
|
||||
component.listEntries$.subscribe((listEntries) => {
|
||||
expect(listEntries.length).toBeGreaterThan(0);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should contain a combination of the current DSO and first page results', () => {
|
||||
expect(component.listEntries).toEqual([searchResult, ...firstPageResults]);
|
||||
it('should contain a combination of the current DSO and first page results', (done) => {
|
||||
component.listEntries$.subscribe((listEntries) => {
|
||||
expect(listEntries).toEqual([searchResult, ...firstPageResults]);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when current page increases', () => {
|
||||
@@ -105,8 +111,11 @@ describe('DSOSelectorComponent', () => {
|
||||
component.currentPage$.next(2);
|
||||
});
|
||||
|
||||
it('should contain a combination of the current DSO, as well as first and second page results', () => {
|
||||
expect(component.listEntries).toEqual([searchResult, ...firstPageResults, ...nextPageResults]);
|
||||
it('should contain a combination of the current DSO, as well as first and second page results', (done) => {
|
||||
component.listEntries$.subscribe((listEntries) => {
|
||||
expect(listEntries).toEqual([searchResult, ...firstPageResults, ...nextPageResults]);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -81,7 +81,7 @@ export class DSOSelectorComponent implements OnInit, OnDestroy {
|
||||
/**
|
||||
* List with search results of DSpace objects for the current query
|
||||
*/
|
||||
listEntries: SearchResult<DSpaceObject>[] = null;
|
||||
listEntries$: BehaviorSubject<SearchResult<DSpaceObject>[]> = new BehaviorSubject(null);
|
||||
|
||||
/**
|
||||
* The current page to load
|
||||
@@ -160,7 +160,7 @@ export class DSOSelectorComponent implements OnInit, OnDestroy {
|
||||
this.loading = true;
|
||||
if (page === 1) {
|
||||
// The first page is loading, this means we should reset the list instead of adding to it
|
||||
this.listEntries = null;
|
||||
this.listEntries$.next(null);
|
||||
}
|
||||
return this.search(query, page).pipe(
|
||||
map((rd) => {
|
||||
@@ -181,15 +181,16 @@ export class DSOSelectorComponent implements OnInit, OnDestroy {
|
||||
).subscribe((rd) => {
|
||||
this.loading = false;
|
||||
if (rd.hasSucceeded) {
|
||||
if (hasNoValue(this.listEntries)) {
|
||||
this.listEntries = rd.payload.page;
|
||||
const currentEntries = this.listEntries$.getValue();
|
||||
if (hasNoValue(currentEntries)) {
|
||||
this.listEntries$.next(rd.payload.page);
|
||||
} else {
|
||||
this.listEntries.push(...rd.payload.page);
|
||||
this.listEntries$.next([...currentEntries, ...rd.payload.page]);
|
||||
}
|
||||
// Check if there are more pages available after the current one
|
||||
this.hasNextPage = rd.payload.totalElements > this.listEntries.length;
|
||||
this.hasNextPage = rd.payload.totalElements > this.listEntries$.getValue().length;
|
||||
} else {
|
||||
this.listEntries = null;
|
||||
this.listEntries$.next(null);
|
||||
this.hasNextPage = false;
|
||||
}
|
||||
}));
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user