Merge branch 'main' into w2p-79768_fix-issues-with-meta-tags

This commit is contained in:
Yura Bondarenko
2021-07-01 22:55:29 +02:00
209 changed files with 3144 additions and 1455 deletions

15
SECURITY.md Normal file
View File

@@ -0,0 +1,15 @@
# Security Policy
## Supported Versions
For information regarding which versions of DSpace are currently under support, please see our DSpace Software Support Policy:
https://wiki.lyrasis.org/display/DSPACE/DSpace+Software+Support+Policy
## Reporting a Vulnerability
If you believe you have found a security vulnerability in a supported version of DSpace, we encourage you to let us know right away.
We will investigate all legitimate reports and do our best to quickly fix the problem. Please see our DSpace Software Support Policy
for information on privately reporting vulnerabilities:
https://wiki.lyrasis.org/display/DSPACE/DSpace+Software+Support+Policy

View File

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

View File

@@ -69,7 +69,6 @@ exports.config = {
plugins: [{
path: '../node_modules/protractor-istanbul-plugin'
}],
framework: 'jasmine',
jasmineNodeOpts: {
showColors: true,
@@ -85,7 +84,7 @@ exports.config = {
onPrepare: function () {
jasmine.getEnv().addReporter(new SpecReporter({
spec: {
displayStacktrace: true
displayStacktrace: 'pretty'
}
}));
}

View File

@@ -1,11 +1,10 @@
<li class="sidebar-section">
<a class="nav-item nav-link shortcut-icon" attr.aria-labelledby="sidebarName-{{section.id}}" [title]="('menu.section.icon.' + section.id) | translate" [routerLink]="itemModel.link">
<a href="javascript:void(0);" class="nav-item nav-link shortcut-icon" attr.aria-labelledby="sidebarName-{{section.id}}" [title]="('menu.section.icon.' + section.id) | translate" [routerLink]="itemModel.link">
<i class="fas fa-{{section.icon}} fa-fw"></i>
</a>
<div class="sidebar-collapsible">
<span id="sidebarName-{{section.id}}" class="section-header-text">
<ng-container
*ngComponentOutlet="(sectionMap$ | async).get(section.id).component; injector: (sectionMap$ | async).get(section.id).injector;"></ng-container>
<a class="nav-item nav-link" tabindex="-1" [routerLink]="itemModel.link">{{itemModel.text | translate}}</a>
</span>
</div>
</li>

View File

@@ -10,14 +10,14 @@
<div class="sidebar-top-level-items">
<ul class="navbar-nav">
<li class="admin-menu-header sidebar-section">
<a class="shortcut-icon navbar-brand mr-0" href="#">
<a class="shortcut-icon navbar-brand mr-0" href="javascript:void(0);">
<span class="logo-wrapper">
<img class="admin-logo" src="assets/images/dspace-logo-mini.svg"
[alt]="('menu.header.image.logo') | translate">
</span>
</a>
<div class="sidebar-collapsible">
<a class="navbar-brand mr-0" href="#">
<a class="navbar-brand mr-0" href="javascript:void(0);">
<h4 class="section-header-text mb-0">{{'menu.header.admin' |
translate}}</h4>
</a>
@@ -33,7 +33,7 @@
<div class="navbar-nav">
<div class="sidebar-section" id="sidebar-collapse-toggle">
<a class="nav-item nav-link shortcut-icon"
href="#"
href="javascript:void(0);"
(click)="toggle($event)">
<i *ngIf="(menuCollapsed | async)" class="fas fa-fw fa-angle-double-right"
[title]="'menu.section.icon.pin' | translate"></i>
@@ -42,7 +42,7 @@
</a>
<div class="sidebar-collapsible">
<a class="nav-item nav-link sidebar-section"
href="#"
href="javascript:void(0);"
(click)="toggle($event)">
<span *ngIf="menuCollapsed | async" class="section-header-text">{{'menu.section.pin' | translate }}</span>
<span *ngIf="!(menuCollapsed | async)" class="section-header-text">{{'menu.section.unpin' | translate }}</span>

View File

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

View File

@@ -3,12 +3,12 @@
value: ((expanded | async) ? 'endBackground' : 'startBackground'),
params: {endColor: (sidebarActiveBg | async)}}">
<div class="icon-wrapper">
<a class="nav-item nav-link shortcut-icon" attr.aria.labelledby="sidebarName-{{section.id}}" [title]="('menu.section.icon.' + section.id) | translate" (click)="toggleSection($event)" href="#">
<a class="nav-item nav-link shortcut-icon" attr.aria.labelledby="sidebarName-{{section.id}}" [title]="('menu.section.icon.' + section.id) | translate" (click)="toggleSection($event)" href="javascript:void(0);">
<i class="fas fa-{{section.icon}} fa-fw"></i>
</a>
</div>
<div class="sidebar-collapsible">
<a class="nav-item nav-link" href="#"
<a class="nav-item nav-link" href="javascript:void(0);" tabindex="-1"
(click)="toggleSection($event)">
<span id="sidebarName-{{section.id}}" class="section-header-text">
<ng-container

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,15 +2,16 @@
<div class="row">
<ng-container *ngVar="(dsoRD$ | async)?.payload as dso">
<div class="col-12 pb-4">
<h2 id="header" class="border-bottom pb-2">{{ 'community.delete.head' | translate}}</h2>
<p class="pb-2">{{ 'community.delete.text' | translate:{ dso: dso.name } }}</p>
<h2 id="header" class="border-bottom pb-2">{{ 'collection.delete.head' | translate}}</h2>
<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)">
<i class="fas fa-times"></i> {{'community.delete.cancel' | translate}}
<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> {{'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> {{'collection.delete.processing' | translate}}</span>
<span *ngIf="!(processing$ | async)"><i class="fas fa-trash"></i> {{'collection.delete.confirm' | translate}}</span>
</button>
</div>
</div>

View File

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

View File

@@ -20,6 +20,7 @@ import { ThemedHomePageComponent } from './themed-home-page.component';
id: 'statistics_site',
active: true,
visible: true,
index: 2,
model: {
type: MenuItemType.LINK,
text: 'menu.section.statistics',

View File

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

View File

@@ -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}}
<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 [routerLink]="[(itemPageRoute$ | async), 'edit']"
class="btn btn-outline-secondary">
{{'item.edit.move.cancel' | translate}}
<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>

View File

@@ -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,8 +88,9 @@ describe('ItemMoveComponent', () => {
const notificationsServiceStub = new NotificationsServiceStub();
describe('ItemMoveComponent success', () => {
beforeEach(() => {
const init = (mockItemDataService) => {
itemDataService = mockItemDataService;
TestBed.configureTestingModule({
imports: [CommonModule, FormsModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule],
declarations: [ItemMoveComponent],
@@ -90,6 +100,7 @@ describe('ItemMoveComponent', () => {
{ provide: ItemDataService, useValue: mockItemDataService },
{ provide: NotificationsService, useValue: notificationsServiceStub },
{ provide: SearchService, useValue: mockSearchService },
{ provide: RequestService, useValue: getMockRequestService() },
], schemas: [
CUSTOM_ELEMENTS_SCHEMA
]
@@ -97,25 +108,20 @@ describe('ItemMoveComponent', () => {
fixture = TestBed.createComponent(ItemMoveComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
});
it('should load suggestions', () => {
const expected = [
collection1,
collection2
];
};
comp.collectionSearchResults.subscribe((value) => {
expect(value).toEqual(expected);
}
);
describe('ItemMoveComponent success', () => {
beforeEach(() => {
init(mockItemDataServiceSuccess);
});
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();
});

View File

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

View File

@@ -6,8 +6,15 @@
</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-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 || {}"
@@ -19,8 +26,10 @@
'alert-danger': updateValue.changeType === 2
}">
</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>

View File

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

View File

@@ -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,15 +337,16 @@ export class EditRelationshipListComponent implements OnInit {
}
ngOnInit(): void {
this.relatedEntityType$ =
observableCombineLatest([
// 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(),
))).pipe(
))) as Observable<[ItemType, ItemType]>;
this.relatedEntityType$ = this.relationshipLeftAndRightType$.pipe(
map((relatedTypes: ItemType[]) => relatedTypes.find((relatedType) => relatedType.uuid !== this.itemType.uuid)),
hasValueOperator()
);
@@ -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))
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(
defaultIfEmpty([]),
map((isLeftItemArray) => isLeftItemArray.map((isLeftItem, index) => {
const relationship = relationships[index];
const nameVariant = isLeftItem ? relationship.rightwardValue : relationship.leftwardValue;
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;
})),
}),
// 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((initialFields) => this.objectUpdatesService.getFieldUpdates(this.url, initialFields).pipe(
map((fieldUpdates) => {
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;
if ((field as RelationshipIdentifiable).type.id === this.relationshipType.id) {
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({}),
);
}),
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());
}
}

View File

@@ -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[]) => {

View File

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

View File

@@ -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,6 +57,7 @@ describe('MetadataFieldWrapperComponent', () => {
expect(component).toBeDefined();
});
describe('with hideIfNoTextContent=true', () => {
it('should not show the component when there is no content', () => {
const parentFixture = TestBed.createComponent(NoContentComponent);
parentFixture.detectChanges();
@@ -66,7 +66,7 @@ describe('MetadataFieldWrapperComponent', () => {
expect(nativeWrapper.classList.contains('d-none')).toBe(true);
});
it('should not show the component when there is DOM content but not text or an image', () => {
it('should not show the component when there is no text content', () => {
const parentFixture = TestBed.createComponent(SpanContentComponent);
parentFixture.detectChanges();
const parentNative = parentFixture.nativeElement;
@@ -82,14 +82,35 @@ describe('MetadataFieldWrapperComponent', () => {
parentFixture.detectChanges();
expect(nativeWrapper.classList.contains('d-none')).toBe(false);
});
});
it('should show the component when there is img content', () => {
const parentFixture = TestBed.createComponent(ImgContentComponent);
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 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 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);
});
});
});

View File

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

View File

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

View File

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

View File

@@ -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')
];
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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}`)
);
}
}

View File

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

View File

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

View File

@@ -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,18 +141,22 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
if (groups.page.length === 0) {
return observableOf(buildPaginatedList(groups.pageInfo, []));
}
return this.authorizationService.isAuthorized(FeatureID.AdministratorOf).pipe(
switchMap((isSiteAdmin: boolean) => {
return observableCombineLatest(groups.page.map((group: Group) => {
if (!this.deletedGroupsIds.includes(group.id)) {
if (hasValue(group) && !this.deletedGroupsIds.includes(group.id)) {
return observableCombineLatest([
this.authorizationService.isAuthorized(FeatureID.CanDelete, hasValue(group) ? group.self : undefined),
this.authorizationService.isAuthorized(FeatureID.CanDelete, group.self),
this.canManageGroup$(isSiteAdmin, group),
this.hasLinkedDSO(group),
this.getSubgroups(group),
this.getMembers(group)
]).pipe(
map(([isAuthorized, hasLinkedDSO, subgroups, members]:
[boolean, boolean, RemoteData<PaginatedList<Group>>, RemoteData<PaginatedList<EPerson>>]) => {
map(([canDelete, canManageGroup, hasLinkedDSO, subgroups, members]:
[boolean, boolean, boolean, RemoteData<PaginatedList<Group>>, RemoteData<PaginatedList<EPerson>>]) => {
const groupDtoModel: GroupDtoModel = new GroupDtoModel();
groupDtoModel.ableToDelete = isAuthorized && !hasLinkedDSO;
groupDtoModel.ableToDelete = canDelete && !hasLinkedDSO;
groupDtoModel.ableToEdit = canManageGroup;
groupDtoModel.group = group;
groupDtoModel.subgroups = subgroups.payload;
groupDtoModel.epersons = members.payload;
@@ -165,15 +169,25 @@ export class GroupsRegistryComponent implements OnInit, OnDestroy {
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
*/

View File

@@ -46,6 +46,7 @@ import { ThemedForbiddenComponent } from './forbidden/themed-forbidden.component
import { ThemedHeaderComponent } from './header/themed-header.component';
import { ThemedFooterComponent } from './footer/themed-footer.component';
import { ThemedBreadcrumbsComponent } from './breadcrumbs/themed-breadcrumbs.component';
import { ThemedHeaderNavbarWrapperComponent } from './header-nav-wrapper/themed-header-navbar-wrapper.component';
export function getBase() {
return environment.ui.nameSpace;
@@ -129,6 +130,7 @@ const DECLARATIONS = [
HeaderComponent,
ThemedHeaderComponent,
HeaderNavbarWrapperComponent,
ThemedHeaderNavbarWrapperComponent,
AdminSidebarComponent,
AdminSidebarSectionComponent,
ExpandableAdminSidebarSectionComponent,

View File

@@ -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>>) => {

View File

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

View File

@@ -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,19 +219,41 @@ describe('AuthEffects', () => {
expect(authEffects.checkTokenCookie$).toBeObservable(expected);
});
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);
});
});
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);
});
});
});
describe('when check token failed', () => {
it('should return a AUTHENTICATED_ERROR action in response to a CHECK_AUTHENTICATION_TOKEN_COOKIE action', () => {
spyOn((authEffects as any).authService, 'checkAuthenticationCookie').and.returnValue(observableThrow(new Error('Message Error test')));
@@ -359,11 +383,17 @@ describe('AuthEffects', () => {
describe('retrieveMethods$', () => {
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 } });
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);
});
@@ -373,15 +403,56 @@ describe('AuthEffects', () => {
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 } });
actions = hot('--a-', { a:
{
type: AuthActionTypes.RETRIEVE_AUTH_METHODS,
payload: { status: authStatus, blocking: false}
}
});
const expected = cold('--b-', { b: new RetrieveAuthMethodsErrorAction() });
const expected = cold('--b-', { b: new RetrieveAuthMethodsErrorAction(false) });
expect(authEffects.retrieveMethods$).toBeObservable(expected);
});
});
});
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}
}
});
const expected = cold('--b-', { b: new RetrieveAuthMethodsSuccessAction(authMethodsMock, true) });
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$', () => {
beforeEach(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -523,7 +523,7 @@ describe('RemoteDataBuildService', () => {
let paginatedLinksToFollow;
beforeEach(() => {
paginatedLinksToFollow = [
followLink('page', undefined, true, true, true, ...linksToFollow),
followLink('page', {}, ...linksToFollow),
...linksToFollow
];
});

View File

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

View File

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

View File

@@ -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}.
*

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ export enum FeatureID {
ReinstateItem = 'reinstateItem',
EPersonRegistration = 'epersonRegistration',
CanManageGroups = 'canManageGroups',
CanManageGroup = 'canManageGroup',
IsCollectionAdmin = 'isCollectionAdmin',
IsCommunityAdmin = 'isCommunityAdmin',
CanDownload = 'canDownload',

View File

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

View File

@@ -579,4 +579,19 @@ describe('RequestService', () => {
});
});
});
describe('uriEncodeBody', () => {
it('should properly encode the body', () => {
const body = {
'property1': 'multiple\nlines\nto\nsend',
'property2': 'sp&ci@l characters',
'sp&ci@l-chars in prop': 'test123',
};
const queryParams = service.uriEncodeBody(body);
expect(queryParams).toEqual(
'property1=multiple%0Alines%0Ato%0Asend&property2=sp%26ci%40l%20characters&sp%26ci%40l-chars%20in%20prop=test123'
);
});
});
});

View File

@@ -265,11 +265,13 @@ export class RequestService {
if (isNotEmpty(body) && typeof body === 'object') {
Object.keys(body)
.forEach((param) => {
const paramValue = `${param}=${body[param]}`;
const encodedParam = encodeURIComponent(param);
const encodedBody = encodeURIComponent(body[param]);
const paramValue = `${encodedParam}=${encodedBody}`;
queryParams = isEmpty(queryParams) ? queryParams.concat(paramValue) : queryParams.concat('&', paramValue);
});
}
return encodeURI(queryParams);
return queryParams;
}
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>>;
/**

View File

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

View File

@@ -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> = () => {
@@ -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)),
);

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

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

View File

@@ -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(),

View File

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

View File

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

View File

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

View File

@@ -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']"

View File

@@ -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']"

View File

@@ -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']"

View File

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

View File

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

View File

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

View File

@@ -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']"

View File

@@ -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']"

View File

@@ -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']"-->

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
<footer class="top-footer text-lg-start">
<footer class="text-lg-start">
<div *ngIf="showTopFooter" class="top-footer">
<!-- Grid container -->
<div *ngIf="showTopFooter" class="container p-4">
<div class=" container p-4">
<!--Grid row-->
<div class="row">
@@ -48,25 +49,31 @@
</div>
<!--Grid row-->
</div>
</div>
<!-- Grid container -->
<!-- Copyright -->
<div class="footer p-1 d-flex justify-content-center align-items-center text-white">
<div class="bottom-footer p-1 d-flex justify-content-center align-items-center text-white">
<div class="content-container">
<p class="m-0">
<a class="text-white" href="http://www.dspace.org/">{{ 'footer.link.dspace' | translate}}</a>
<a class="text-white"
href="http://www.dspace.org/">{{ 'footer.link.dspace' | translate}}</a>
{{ 'footer.copyright' | translate:{year: dateObj | date:'y'} }}
<a class="text-white" href="https://www.lyrasis.org/">{{ 'footer.link.lyrasis' | translate}}</a>
<a class="text-white"
href="https://www.lyrasis.org/">{{ 'footer.link.lyrasis' | translate}}</a>
</p>
<ul class="footer-info list-unstyled small d-flex justify-content-center mb-0">
<li>
<a class="text-white" href="#" (click)="showCookieSettings()">{{ 'footer.link.cookies' | translate}}</a>
<a class="text-white" href="#"
(click)="showCookieSettings()">{{ 'footer.link.cookies' | translate}}</a>
</li>
<li>
<a class="text-white" routerLink="info/privacy">{{ 'footer.link.privacy-policy' | translate}}</a>
<a class="text-white"
routerLink="info/privacy">{{ 'footer.link.privacy-policy' | translate}}</a>
</li>
<li>
<a class="text-white" routerLink="info/end-user-agreement">{{ 'footer.link.end-user-agreement' | translate}}</a>
<a class="text-white"
routerLink="info/end-user-agreement">{{ 'footer.link.end-user-agreement' | translate}}</a>
</li>
</ul>
</div>

View File

@@ -1,11 +1,10 @@
:host {
.top-footer {
background-color: var(--ds-top-footer-bg);
border-top: var(--ds-footer-border);
footer {
background-color: var(--ds-footer-bg);
text-align: center;
padding: var(--ds-footer-padding);
z-index: var(--ds-footer-z-index);
border-top: var(--ds-footer-border);
padding: var(--ds-footer-padding);
p {
margin: 0;
@@ -15,12 +14,18 @@
height: var(--ds-footer-logo-height);
}
.footer {
background-color: var(--ds-footer-bg);
.top-footer {
background-color: var(--ds-top-footer-bg);
padding: var(--ds-footer-padding);
margin: calc(var(--ds-footer-padding) * -1);
}
.bottom-footer {
ul {
li {
display: inline-flex;
a {
padding: 0 calc(var(--bs-spacer) / 2);
color: inherit

View File

@@ -1,3 +1,4 @@
<div [ngClass]="{'open': !(isNavBarCollapsed | async)}">
<ds-themed-header></ds-themed-header>
<ds-themed-navbar></ds-themed-navbar>
</div>

View File

@@ -5,7 +5,3 @@
position: sticky;
}
}
:host {
z-index: var(--ds-nav-z-index);
}

View File

@@ -0,0 +1,3 @@
:host {
z-index: var(--ds-nav-z-index);
}

View File

@@ -0,0 +1,25 @@
import { Component } from '@angular/core';
import { ThemedComponent } from '../shared/theme-support/themed.component';
import { HeaderNavbarWrapperComponent } from './header-navbar-wrapper.component';
/**
* Themed wrapper for BreadcrumbsComponent
*/
@Component({
selector: 'ds-themed-header-navbar-wrapper',
styleUrls: ['./themed-header-navbar-wrapper.component.scss'],
templateUrl: '../shared/theme-support/themed.component.html',
})
export class ThemedHeaderNavbarWrapperComponent extends ThemedComponent<HeaderNavbarWrapperComponent> {
protected getComponentName(): string {
return 'HeaderNavbarWrapperComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../themes/${themeName}/app/header-nav-wrapper/header-navbar-wrapper.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import(`./header-navbar-wrapper.component`);
}
}

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